Skip to content

perf: Decode source image once per file in ImageProxyMiddleware - #157

Merged
bitbiter-dev merged 2 commits into
masterfrom
perf/153-decode-image-once
Sep 18, 2026
Merged

bitbiter-dev merged 2 commits into
masterfrom
perf/153-decode-image-once

Conversation

@bitbiter-dev

@bitbiter-dev bitbiter-dev commented May 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • ImageProxyMiddleware previously decoded the source image three times — once per generator — because each generator called Image.LoadAsync from its own MemoryStream
  • The middleware now decodes once and passes a Clone() to each generator
  • IImageProcessor and IImageProxyGenerator interfaces updated to accept Image<Rgba32> directly instead of a Stream; Image<Rgba32> is the only pixel format that satisfies all three generators (blurhash requires Rgba32)

Changes

  • ImageProcessor.cs — IImageProcessor + ImageSharpProcessor receive Image<Rgba32>; all Image.LoadAsync calls removed
  • ImageProxyGenerators.cs — IImageProxyGenerator.GenerateAsync signature updated to Image<Rgba32>
  • ImageProxyMiddleware.cs — single decode before Task.WhenAll; image.Clone() per generator
  • Tests updated: MinimalPng generated via ImageSharp at test time; mock matchers updated to Arg.Any<Image<Rgba32>>()

Why the clone is needed

An earlier revision of this description said the clone keeps "the Task.WhenAll parallel path race-free." That framing is wrong and worth correcting for the record: Mutate() is synchronous and runs before the first await, so Task.WhenAll buys 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 to Rgb24 (3 B/px). With Worker:MaxConcurrentFiles defaulting 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 errors
  • dotnet test src/Anichron.slnx — 596 passed, 0 failed (API 325, Worker 198, Core 47, Infrastructure 26)
  • dotnet format src/ --verify-no-changes — no diff

Rebased / 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.OpenApi advisory (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

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>
@bitbiter-dev
bitbiter-dev merged commit 8e2a1f9 into master Sep 18, 2026
6 checks passed
@bitbiter-dev
bitbiter-dev deleted the perf/153-decode-image-once branch September 18, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(worker): Decode image once per file in ImageSharpProcessor

1 participant