Repository navigation
Conversation
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-platform loading, cancellation, and async-completion changes warrant final human review despite the added regression coverage.
No open issues 🎉
What changed in this PR
Addresses #25556 by reducing synchronous image decoding and correcting stream-source reload behavior in Uno’s imaging pipeline.
Changes:
- Adds header-based size reading for Skia, with a compatible decoding fallback.
- Removes redundant decoding and updates source invalidation, cancellation, and async completion.
- Adds regression tests for image formats, orientation, decoding counts, and reload behavior.
| File | Description |
|---|---|
| src/Uno.UI/UI/Xaml/Media/Imaging/WriteableBitmap.cs | Takes dimensions from the buffer decode. |
| src/Uno.UI/UI/Xaml/Media/Imaging/BitmapSource.cs | Reads dimensions and reloads replaced sources. |
| src/Uno.UI/UI/Xaml/Media/Imaging/BitmapImage.cs | Discards cancelled stream-load results. |
| src/Uno.UI/UI/Xaml/Media/ImageSource.crossruntime.cs | Cancels obsolete opens without subscribers. |
| src/Uno.UI.RuntimeTests/Microsoft/UI/Xaml/Media/Imaging/Given_WriteableBitmap.cs | Tests decode counts and async completion. |
| src/Uno.UI.RuntimeTests/Microsoft/UI/Xaml/Media/Imaging/Given_BitmapSource.cs | Tests size reading, orientation, and reloads. |
| src/Uno.UI.RuntimeTests/Helpers/CountingImageCodec.cs | Adds codec instrumentation for tests. |
| src/Uno.UI.Composition.Skia/Composition/Uno/Drawing/SkiaImageDecoderBackend.cs | Exposes Skia size reading. |
| src/Uno.UI.Composition.Skia/Composition/Uno/Drawing/SkiaImageDecoder.cs | Reads header dimensions with orientation applied. |
| src/Uno.UI.Composition.Drawing/IImageEncoderDecoder.cs | Adds the default size-reading contract. |
Review effort: Balanced
mikernet
added a commit
to Singulink/uno
that referenced
this pull request
Oct 17, 2026
Port of the reload part of unoplatform#25561 (9f2f289). SetSource kept the image opened from the previous stream: an Image already showing the bitmap kept its picture (or showed nothing, if the bitmap had no stream yet), and an Image shown later got the old image. WinUI's CImageSource::SetSource cancels the current decode and drops the current image. - SetSourceCore now invalidates the source the same way. - SetSourceAsync subscribed, then invalidated, which opened the source twice when nothing else subscribed, and completed at once with data the source still held. It now only subscribes, after SetSourceCore's invalidation, and returns when that invalidation opened the source and failed synchronously, since no other result follows. - Tests: When_SetSourceAsync_On_Opened_Source_Then_Completes_Once_New_Source_Opens, When_SetSource_On_Shown_Source_Then_Image_Shows_New_Source, When_SetSource_On_Opened_Source_Then_Image_Shown_Later_Gets_New_Source, When_SetSource_While_Previous_Source_Opens_Unseen_Then_Its_Result_Is_Dropped (Given_BitmapSource) and When_SetSourceAsync_Opens_And_Fails_For_A_Subscriber_Then_It_Completes (Given_WriteableBitmap). 6.8 differences: - Not ported: the size read without decoding (7ba72ff), as 6.8 already reads only the codec header, and the test that counts opens through the codec seam 6.8 doesn't have. - With the unoplatform#25124 port, ImageSource.ReleaseImageData already cancels an open in flight whenever the source is invalidated, so upstream's cancel in InvalidateSource has nothing left to do. BitmapImage's Publish already drops a stream open superseded while it decoded, and releases its surface. - After the unoplatform#25124 port, an error left by SetSourceCore's invalidation can only come from that open failing synchronously, so ForceLoad keeps upstream's check of the data's kind. Checking for no open in flight and no data would also return when nothing subscribes, before anything opens the source.
2 of 5 tasks
Contributor
|
Tick the box to add this pull request to the merge queue (same as
|
SetSource set PixelWidth and PixelHeight from a full decode of the image on the UI thread (every frame of an animated one) and threw the pixels away; the image is decoded again when it opens, and a WriteableBitmap decoded it a second time into its pixel buffer. For a 4000x2667 JPEG that is a 43 MB decode, about 75 ms per call on desktop. 6.8 read only the header with SKCodec, but the backend-neutral codec seam had no way to do that. IImageEncoderDecoder gains TryGetSize. It is a default interface method, so a codec an app registers keeps working: the default decodes and reads the first frame, as SetSource did. The Skia codec reads the header and applies the EXIF origin, as TryDecode does. SetSource now also disposes the stream clone it reads the size from. WriteableBitmap takes its size from the decode that fills its buffer instead, so the buffer is sized from the frame copied into it.
SetSource kept the image opened from the previous stream: an Image already showing the bitmap kept its picture (or showed nothing, if the bitmap had no stream yet), and an Image shown later got the old image. WinUI's CImageSource::SetSource cancels the current decode and drops the current image. SetSourceCore now invalidates the source the same way, which also cancels an open of the previous stream still in flight and gives the source its new version. SetSourceAsync subscribed, then invalidated. With no other subscriber that opened the source twice: a BitmapImage could decode twice on desktop (the first open is only cancelled if it hasn't started), and a WriteableBitmap copied its pixels into an image twice. When the source still held opened data, subscribing completed at once with it, so the await returned before the new source opened. It now only subscribes, after SetSourceCore's invalidation, and returns when that invalidation opened the source and failed synchronously, since no other result follows.
mikernet
force-pushed
the
dev/mikernet/bitmap-source-read-size
branch
from
November 12, 2026 22:04
9f2f289 to
f5ac544
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
GitHub Issue: closes #25556
PR Type:
🐞 Bugfix (a performance regression from 6.8, and a source that doesn't reload)
What changed? 🚀
BitmapSource.SetSourcefully decoded the image on the UI thread to read its size, then threw the pixels away; 6.8 read only the header. Now:IImageEncoderDecodergainsTryGetSize(Stream, out int pixelWidth, out int pixelHeight): the sizeTryDecodeproduces with no target size, EXIF orientation applied. It is a default interface method, likeIFontProvider.LoadFontFile, so it isn't breaking: the default decodes, asSetSourcedid.SKCodec.Createand swaps width and height for the quarter-turn EXIF origins (5–8), asTryDecodedoes.SetSourcereads the size with it, still synchronously, and disposes the stream clone it reads;WriteableBitmap.SetSourcetakes the size from the decode that fills its buffer.Setting a stream now also reloads the source for whatever shows it, as WinUI's
CImageSource::SetSourcedoes. This was wrong in 6.8 too:Imageshowing the bitmap, or shown later, gets the new image instead of the previous one.SetSourcenow invalidates the source, which since fix(imaging): Release a decoded image when nothing displays it #25124 also cancels a previous open still in flight, with its size and events.SetSourceAsyncused to subscribe and then invalidate, which opened the source twice when nothing else subscribed, and completed with the previous image when one was open. It now only subscribes.The managed decoder keeps the default here; reading its headers is #25566.
Measurements
A 4000x2667 JPEG (2.4 MB), Release builds:
SetSource, UI thread, desktop (median)SetSource, UI thread, WebAssembly (median)BitmapImage.SetSource(nothing showing it)BitmapImage.SetSourceAsync, desktopWriteableBitmap.SetSource/SetSourceAsync¹ Medians of several runs of master's
SetSourcesteps on this branch, measured before #25124 merged; it didn't change the cost of theSetSourcepath.Tests
Given_BitmapSource:When_SetSource_Then_PixelSize_Is_Read_With_TryGetSize: JPEG, PNG, an EXIF-rotated JPEG, an animated GIF, WebP and BMP get their size, andSetSourcecalls noTryDecode.When_SetSource_Then_Skia_Reads_Only_The_Header: for a JPEG, a PNG, a BMP and a GIF, reading the size reads under a quarter of the file.When_SetSource_Exif_Oriented_Then_PixelSize_Matches_Decode(each of the 8 orientations) andWhen_SetSource_Webp_Then_PixelSize_Matches_Decode(VP8L, VP8 and VP8X): the size matches the decode's.When_SetSource_Undecodable_Then_PixelSize_Is_Zero: a text file and a truncated PNG don't throw and leave the size at 0. A file whose header reads but whose pixels don't decode keeps the header's size until its open fails and resets it, as in 6.8.When_SetSource_With_Codec_Without_TryGetSize_Then_PixelSize_Is_Read_From_Decode: the default.When_SetSource_On_Shown_Source_Then_Image_Shows_New_Source,When_SetSource_On_Opened_Source_Then_Image_Shown_Later_Gets_New_Source,When_SetSource_While_Previous_Source_Opens_Unseen_Then_Its_Result_Is_DroppedandWhen_SetSourceAsync_On_Opened_Source_Then_Completes_Once_New_Source_Opens.Given_WriteableBitmap:When_SetSource_Then_Stream_Is_Decoded_Once,When_SetSourceAsync_Then_Source_Is_Opened_OnceandWhen_SetSourceAsync_Opens_And_Fails_For_A_Subscriber_Then_It_Completes.Taken out one at a time on desktop, each of these fails a test: master's
BitmapSourceandWriteableBitmap; the Skia override (the default reads the whole file), its EXIF swap and its null check; the default's decode; the invalidation inSetSource; theSetSourceAsyncerror guard. The cancel of an unobserved open and the check that drops a supersededBitmapImagestream open, which this PR used to add, came with #25124.Runs of the image test classes (
Given_BitmapSource,Given_WriteableBitmap,Given_BitmapImage,Given_Image,Given_Image_Release,Given_ImageSource,Given_ImageBrush) on current master: Skia desktop 159 passed / 1 failed. Before the update, WebAssembly: 106 passed / 1 failed. The failure,When_WriteableBitmap_Assigned_With_Data_Present, fails the same on master without this PR (125% scaling).PR Checklist ✅
Screenshots Compare Test Runresults.