perf: Decode source image once per file in ImageProxyMiddleware - #157
Merged
Merged
Conversation
Previously each generator independently decoded the source bytes via its own Image.LoadAsync call, meaning a large file was fully decoded three times. The middleware now owns the single decode and passes image.Clone() to each generator so the Task.WhenAll parallel path remains race-free while generators mutate their own copy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.
Summary
ImageProxyMiddlewarepreviously decoded the source image three times — once per generator — because each generator calledImage.LoadAsyncfrom its ownMemoryStreamClone()to each generatorIImageProcessorandIImageProxyGeneratorinterfaces updated to acceptImage<Rgba32>directly instead of aStream;Image<Rgba32>is the only pixel format that satisfies all three generators (blurhash requiresRgba32)Changes
ImageProcessor.cs—IImageProcessor+ImageSharpProcessorreceiveImage<Rgba32>; allImage.LoadAsynccalls removedImageProxyGenerators.cs—IImageProxyGenerator.GenerateAsyncsignature updated toImage<Rgba32>ImageProxyMiddleware.cs— single decode beforeTask.WhenAll;image.Clone()per generatorMinimalPnggenerated via ImageSharp at test time; mock matchers updated toArg.Any<Image<Rgba32>>()Why the clone is needed
An earlier revision of this description said the clone keeps "the
Task.WhenAllparallel path race-free." That framing is wrong and worth correcting for the record:Mutate()is synchronous and runs before the firstawait, soTask.WhenAllbuys almost no CPU parallelism here.The clone is required for mutation isolation. Each generator resizes its image in place, so sharing one instance would compound the resizes — the preview would be generated from an already-thumbnailed image. That is a correctness requirement regardless of whether the generators run concurrently.
Known trade-off
This reduces decode work from 3× to 1× per file, which is what #153 asked for, but it increases peak memory: the source buffer stays alive for the whole scope alongside a full-res clone, and
Rgba32(4 B/px) is now forced where JPEG previously decoded toRgb24(3 B/px). WithWorker:MaxConcurrentFilesdefaulting to 4, the ceiling scales accordingly.Tracked as a follow-up in #165 rather than expanded here, since the likely fix (a downscale chain) changes proxy pixel output and needs its own quality review.
Test plan
dotnet build src/Anichron.slnx— Build succeeded, 0 warnings, 0 errorsdotnet test src/Anichron.slnx— 596 passed, 0 failed (API 325, Worker 198, Core 47, Infrastructure 26)dotnet format src/ --verify-no-changes— no diffRebased / brought up to date
This PR sat open from 2026-05-21 with no review. Master has since advanced, and separately the build had broken on a transitive
Microsoft.OpenApiadvisory (fixed in #166). Current master is merged in here; the merge was conflict-free and the full suite passes on the combined state.Closes #153
🤖 Generated with Claude Code