Skip to content

Show a preview for images too large for the browser to display (BL-16597) - #8111

Merged
andrew-polk merged 8 commits into
masterfrom
BL-16597LargeImagePreview
Aug 4, 2026
Merged

Show a preview for images too large for the browser to display (BL-16597)#8111
andrew-polk merged 8 commits into
masterfrom
BL-16597LargeImagePreview

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Chromium — and therefore WebView2 — refuses to decode an image whose pixel count times 4 bytes/pixel overflows a signed 32-bit int (about 536.9 megapixels). The <img> fires error a moment after the bytes arrive and nothing paints, which is why the image chooser showed an empty preview pane for a 30000x23756 scan. Decoding at a reduced size doesn't help: createImageBitmap() with resizeWidth and the WebCodecs ImageDecoder with desiredWidth both fail identically, because the limit is tested against the image's natural size before any scaling is applied. And well below that hard ceiling, handing the renderer a hundred-megapixel image still costs seconds of decode and gigabytes of RAM to fill a preview pane a few hundred pixels tall.

So when a picked file is over 40 megapixels, imageGallery/localFilePreview now serves a downscaled JPEG instead of the original:

  • WIC's DecodePixelWidth/DecodePixelHeight does the scaling as part of the decode, so a 713 megapixel JPEG costs about 2.5 seconds and under 100MB rather than the ~2.6GB a full decode would need.
  • Any EXIF rotation is baked into the stand-in, since it is re-encoded without an EXIF block.
  • The stand-in is generated once per picked file and reused, because the gallery asks for the same URL as both the thumbnail and the large preview. It is deleted when another file is picked, and when the project's lifetime scope is disposed.
  • Anything WIC can't open (SVG, and other formats the browser handles fine anyway) falls back to serving the original.

imageGallery/pickLocalImageFile now also reports the original file's pixel dimensions and byte count, so the gallery describes the file the user actually chose rather than the downscaled stand-in it is being shown.

Tests cover reading dimensions (including the not-an-image fallback), the below-threshold "serve the original" case, and the downscaling itself.

Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16597

Devin review


This change is Reviewable

JohnThomson added a commit that referenced this pull request Jul 27, 2026
The image chooser accepts .tif/.tiff, but the orientation tag was only
looked for at the JPEG APP1 query path, so a rotated TIFF was treated as
upright and previewed sideways. Try the TIFF top-level IFD path as well.

Also: the comment in HandleLocalFilePreview claimed the stand-in is
"generated once and reused". That is true of the stand-in, but for an image
small enough to serve as-is there is nothing to cache, so the (cheap) header
read does repeat per request. Say what actually happens.

Both noticed by Devin (informational flags on PR #8111).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JohnThomson added a commit that referenced this pull request Jul 27, 2026
The cached downscaled stand-in was keyed only on "one exists", so nothing in
HandleLocalFilePreview itself established that it belonged to the file being
requested; that was an invariant maintained at a distance, by the order in
which HandlePickLocalImageFile cleared the cache and reassigned the
authorized path. Unreachable in practice — picking a file means working a
modal dialog, and the front-end only asks for the preview URL after the pick
replies — but it is cheaper to make the cache say what it is about than to
keep the reasoning.

Remembering the source file also lets us remember "this one needs no
stand-in", so an image small enough to serve as-is is no longer re-examined
on every request.

Devin raised this on PR #8111; the suggested hardening is what is implemented
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs
JohnThomson added a commit that referenced this pull request Jul 27, 2026
This is the only place in Bloom that uses WPF's WIC-backed imaging, and the
API server calls it from a thread-pool thread. That is fine as long as the
bitmaps are frozen, which MakeBrowserSafePreview does — but NUnit runs this
project's tests on an STA thread, so nothing here actually exercised the
apartment the real caller uses. Run it on an MTA thread and check the result.

Raised by Devin on PR #8111, which asked for a runtime sanity check; a test
seemed better than checking once by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs Outdated
Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs
Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5) Consulted Devin on 2026-07-27, up to commit 407e275740ee386d43d4ac416a835ea108f44e84 — four reviews in all, one per pushed commit.

What it raised, and where each landed:

  • Transparent huge images preview as a black block — real; left open above for John to decide how to encode the stand-in. The only finding still needing a person.
  • Small TIFFs still don't preview — real, and pre-existing (TIFFs have never previewed here); left open above as a scope question, since making them work is new behaviour rather than part of BL-16597.
  • Preview cache wasn't keyed to the file it was made from — fixed in e1237d202d; thread resolved.
  • WPF/WIC imaging on the server thread — checked and now covered by a test that runs it on an MTA thread, 407e275740; thread resolved.
  • Reporting the real file size might trip size gating in the gallery — checked, no such gating exists; thread resolved.
  • EXIF orientation only read from JPEGs, not TIFFs — fixed in 86057f3d1a. Devin separately verified the orientation transforms (including transpose/transverse) as correct.
  • Plus assorted informational notes; the ones worth acting on became the fixes above.

CI (pr-automation, track / set-waiting) is green. Greptile has not posted on this PR — nor on #8110, opened twenty minutes earlier by someone else — while every earlier PR today got a review within about three minutes, so it looks like Greptile is down rather than slow.

Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5) Consulted Devin again on 2026-07-27, up to commit d784d7f483ffab85d52afce85c85a582326d02a9 — six reviews now, one per pushed commit.

Since the last log, John made the calls on the two findings that were waiting, and both are now fixed with their threads resolved:

  • Transparent huge images previewing black — fixed in 72c2959731 by compositing onto white before the JPEG encode. The test for it was confirmed to fail without the fix.
  • TIFFs not previewing — fixed in the same commit. The deciding argument was that Bloom already converts non-web formats to PNG on import (verified by running the import path: scan.tif lands in the book folder as scan.png), so the preview pane was the one place not applying a rule Bloom already has.
  • EXIF orientation applied by hand / rotated paths untested — new this round, and now covered in d784d7f483. All eight orientations are tested against a JPEG with a hand-built EXIF header. That also settles Devin's "only correct if WPF doesn't auto-rotate" caveat empirically: if WPF were auto-rotating, the swap would be applied twice and cases 5-8 would fail.

One thing to be aware of when reading Devin's page directly: its last three jobs each re-list findings that were already fixed, quoting line numbers from before the fix — the transparency bug is still reported at ImageGalleryApi.cs:630 (new JpegBitmapEncoder), which at this commit is line 725 and is wrapped in FlattenOntoWhite. It appears to accumulate findings across the PR's life rather than re-evaluating each against the current file. Every one of them has a resolved thread here recording the fix.

CI green. Greptile still silent on this PR — as on #8110 — so it has been recorded as timed out rather than clean.

@JohnThomson
JohnThomson marked this pull request as ready for review July 27, 2026 19:13
JohnThomson and others added 7 commits August 4, 2026 12:12
…597)

Chromium (and therefore WebView2) refuses to decode an image whose pixel
count times 4 bytes overflows a signed 32-bit int, so the image chooser
showed an empty preview pane for a 30000x23756 scan. Decoding at a reduced
size does not help: the limit is tested against the image's natural size
before any scaling.

So when a picked file is over 40 megapixels, the localFilePreview endpoint
now serves a downscaled JPEG instead of the original. WIC's DecodePixelWidth
does the scaling as part of the decode, so a 713 megapixel JPEG costs about
2.5 seconds and under 100MB rather than the ~2.6GB a full decode would need.
Any EXIF rotation is baked into the stand-in, since it is re-encoded without
an EXIF block. The stand-in is generated once per picked file and reused,
because the gallery asks for the same URL as both thumbnail and large
preview; it is deleted when another file is picked or when the project's
lifetime scope is disposed.

pickLocalImageFile now also reports the original file's dimensions and byte
count, so the gallery describes the file the user actually chose rather than
the downscaled stand-in it is being shown.

Tests cover reading dimensions (including the not-an-image fallback), the
below-threshold "serve the original" case, and the downscaling itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image chooser accepts .tif/.tiff, but the orientation tag was only
looked for at the JPEG APP1 query path, so a rotated TIFF was treated as
upright and previewed sideways. Try the TIFF top-level IFD path as well.

Also: the comment in HandleLocalFilePreview claimed the stand-in is
"generated once and reused". That is true of the stand-in, but for an image
small enough to serve as-is there is nothing to cache, so the (cheap) header
read does repeat per request. Say what actually happens.

Both noticed by Devin (informational flags on PR #8111).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cached downscaled stand-in was keyed only on "one exists", so nothing in
HandleLocalFilePreview itself established that it belonged to the file being
requested; that was an invariant maintained at a distance, by the order in
which HandlePickLocalImageFile cleared the cache and reassigned the
authorized path. Unreachable in practice — picking a file means working a
modal dialog, and the front-end only asks for the preview URL after the pick
replies — but it is cheaper to make the cache say what it is about than to
keep the reasoning.

Remembering the source file also lets us remember "this one needs no
stand-in", so an image small enough to serve as-is is no longer re-examined
on every request.

Devin raised this on PR #8111; the suggested hardening is what is implemented
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the only place in Bloom that uses WPF's WIC-backed imaging, and the
API server calls it from a thread-pool thread. That is fine as long as the
bitmaps are frozen, which MakeBrowserSafePreview does — but NUnit runs this
project's tests on an STA thread, so nothing here actually exercised the
apartment the real caller uses. Run it on an MTA thread and check the result.

Raised by Devin on PR #8111, which asked for a runtime sanity check; a test
seemed better than checking once by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the reviewers turned up, both decided by John.

Substituting a stand-in was keyed on size alone, but size is only one of the
two reasons a browser won't display a picked file. The chooser's filter
accepts .tif/.tiff and no browser will draw one, so a TIFF showed the same
empty pane this card is about — and, once big TIFFs started being converted
on their way past the size threshold, whether a TIFF previewed depended on
how many pixels it had, which is not explicable to anyone. Bloom already
applies the rule "convert what browsers can't draw so they can draw it" when
importing: a TIFF lands in the book folder as a PNG. This is the same rule,
applied to the preview. Matched on extension rather than on the bytes,
deliberately: ReplyWithImage takes the response's content type from the
extension, so the extension is what the browser goes on. An image already
smaller than kPreviewMaxDimension is no longer enlarged, since a stand-in
made only because of format has no reason to grow.

A stand-in is a JPEG, which has no alpha channel, so a picture with
see-through areas previewed with them solid black — whatever sits in the
colour channels under a transparent pixel. Composite onto white first. Done
unconditionally rather than only for images carrying transparency: deciding
that reliably means enumerating the pixel formats that can hold alpha and
checking indexed palettes for a transparent entry, against one pass over an
image already capped at 1600px. It is pixel arithmetic rather than the WPF
rendering stack, so it stays safe on the API server's threads.

The transparency test was confirmed to fail without the fix (corner pixel
came out 0,0,0), so it is testing what it claims to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Devin pointed out that every image fixture so far was orientation 1, so none
of the rotated paths were exercised and a regression in them would go
unnoticed. It also observed that baking in the rotation by hand is only
correct if WPF's decoder doesn't already do it — true as far as anyone knew,
but nothing established it.

Both are now settled by the same test. A JPEG fixture carrying a chosen EXIF
orientation is built by splicing a hand-written APP1 segment in after the
start-of-image marker (System.Drawing cannot construct a PropertyItem to
attach one, and pulling WPF into the test project just to write a fixture
costs more than twenty bytes of well-specified header). All eight
orientations are then checked: 5-8 must report the stored dimensions
swapped, 1-4 unswapped. Those first four would pass even if the tag were
being ignored entirely, but 5-8 cannot — and they would also fail if WPF
were auto-rotating, since the swap would then be applied twice.

Also covers an orientation value outside 1-8 being ignored rather than
reaching the rotation table.

Riding along: a "seen again" note on the existing papercut about the nine
tests build/agent-dotnet.sh cannot run, which cost time again on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Noticed while preflighting this branch. One full-suite run showed 12
failures rather than the usual 9 environmental ones; a second run at the same
commit showed 9, and the three extras passed in isolation. None of them are
near this branch's subject. Recording it because the cost is that a run of
12 can no longer be read at a glance — you have to run the suite twice to
find out which failures are noise.

Docs only; no product code in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JohnThomson
JohnThomson force-pushed the BL-16597LargeImagePreview branch from 6a28662 to 202bc79 Compare August 4, 2026 17:19
…BL-16597)

Devin caught this on the rebased branch. RegisterEndpointHandler's requiresSync
parameter defaults to true, and imageGallery/localFilePreview was registered with
only three arguments, so it ran inside BloomApiHandler's main SyncObj. That lock
is what serializes most api requests against each other, and this handler holds it
for as long as it takes to decode and re-encode the picked image — about two and a
half seconds for the 713 megapixel scan in the tests. Every other synchronized
request waits that out, so choosing a very large image in the chooser could briefly
freeze unrelated parts of the UI.

Nothing here needs that lock. The only state shared between requests is the preview
cache, and _previewLock already guards every read and write of it (and still
coalesces the gallery's near-simultaneous thumbnail and large-preview requests for
the same file into a single build). The endpoint is also not on the short list that
BloomApiHandler routes to ThumbnailsAndPreviewsSyncObj, so opting out is the same
move the i18n endpoints already make, for the same reason.

One consequence worth recording: serving the file (ReplyWithImage) already happened
outside _previewLock, and pickLocalImageFile still takes the global lock, so the two
can now overlap where previously they could not. If a new file were picked in the
window between choosing the path and streaming it, the temp stand-in could be deleted
mid-serve and the preview would fail to load. That needs a modal file dialog to be
opened and confirmed inside a file-serve window, and the worst outcome is a broken
preview image, so it is left alone rather than paid for with a longer lock hold.

Riding along, per the papercut convention: the entry about three intermittently
failing C# tests no longer opens by counting them "on top of the nine environmental
ones". Master's PR #8107 fixed those nine and deleted their papercut, and a full run
this session came back 0 of 3027 — so the entry now states its own case, and records
that a green baseline makes those three the only remaining noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomExe/web/controllers/ImageGalleryApi.cs
@JohnThomson

Copy link
Copy Markdown
Contributor Author

(Claude Opus 5 (1M context)) Consulted Devin on 2026-08-04, across the rebase — two jobs, up to commit 336da95eefbba7c42635bfafbe1475296bdd9853.

The branch was rebased onto current master (it was 173 commits behind and had stopped being mergeable), so every commit has a new SHA. Devin re-reviewed twice: once on the rebased branch and once after the fix below.

Two findings were acted on, each with its own thread:

  • Preview generation held Bloom's global API lock. Correct, and now fixed in 336da95e — the endpoint is registered requiresSync: false, since its own _previewLock is what actually guards the shared preview cache. Thread
  • Reported dimensions are the original's while the served pixels are the stand-in's. Not an issue — Devin noted it couldn't check the gallery package; it is installed here, and it uses those numbers only for the too-large test and for display text, never for layout. Thread

The rest of what it reported describes code this branch has already changed, and each already has a resolved thread from the 27 July run: the transparency bug (the JPEG encode is wrapped in FlattenOntoWhite), small TIFFs not rendering (any non-renderable extension gets a stand-in regardless of size), EXIF orientation being read only from the JPEG APP1 block (the TIFF query is tried too), and the size field possibly being gated on (it is only displayed). Notably the lock finding above was itself re-reported on the job that reviewed the commit fixing it, so Devin appears to accumulate findings over a PR's life rather than re-checking each against the current file — worth knowing before reading its page directly.

Also green at this commit: CI (pr-automation), the full C# suite (3014 passed, 0 failed, 13 skipped — the first entirely green full run on this branch, now that master's #8107 fixed the nine environmental failures), vitest (581 passed), typecheck and lint. No other review bot is configured on this repo.

@andrew-polk

Copy link
Copy Markdown
Contributor

I think I'm lost on the premise of this change...
The card says the image is pasted in from edit mode. And edit mode displays it. Then the image chooser is opened and doesn't display it. But the PR states the the problem is in wv2 displaying it.
Why can't the image chooser display the image the same way edit mode does?

@JohnThomson JohnThomson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 5 (1M context)] Good question — the confusion dissolves once you notice that steps 2 and 3 of the card are looking at two different files.

Step 2, pasting into Edit, imports the image. Import resizes anything larger than 3840x2800 and saves the smaller copy into the book folder — the card predicts the result itself: "should shrink to about 3535x2800". So what edit mode displays is a ~10 megapixel copy, which WebView2 draws without complaint. That resize is also where the ~8 seconds in step 2 goes.

Step 3, choosing that image in the chooser, points at the original file still sitting in the Van Gogh folder: 30000x23756, about 713 megapixels. Nothing has touched it. WebView2 gives up somewhere above 537 megapixels (int32 max divided by 4 bytes per pixel), firing error instead of load, so the pane stays empty.

So both statements are true and not in tension: WebView2 genuinely cannot draw the original, and edit mode never asks it to — it only ever sees the imported copy. The image chooser is the one place in Bloom that displays a file before it has been imported, which is why this is the only place the problem appears.

On the duplication question, which is the fair one to ask: the server does already have image processing that resizes (RuntimeImageProcessor / ImageUtils.AdjustImageForDisplay, down to the same 3840x2800). Two things stop it covering this case:

  1. It only runs for files that are already in the book folder — processImage = !isSvg && sourceDir == CurrentBook?.FolderPath in BloomServer. The chooser is previewing a file that is still just sitting on the user's disk, so it goes out untouched.
  2. It opens the image with PalasoImage.FromFileRobustly before it can measure or resize it, which is a full decode — the 8 seconds you see on import, and about 2.6GB for this file. Doing that in the chooser would mean paying it just to look at a candidate the user may not even pick.

The new code's one distinctive move is asking WIC to scale during the decode (DecodePixelWidth), so the huge file never becomes a full bitmap in memory at all. So this isn't a second copy of the resize logic — it is the case that logic can't reach, minus the expensive part of it.

@JohnThomson+AGNT made 1 comment.
Reviewable status: 0 of 4 files reviewed, 8 unresolved discussions (waiting on JohnThomson).

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrew-polk reviewed 4 files and all commit messages, and resolved 8 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on JohnThomson).

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on JohnThomson).

@andrew-polk
andrew-polk merged commit f016ecb into master Aug 4, 2026
1 of 2 checks passed
@andrew-polk
andrew-polk deleted the BL-16597LargeImagePreview branch August 4, 2026 22:48
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.

3 participants