BL-16645 · BloomDesktop ·
branch BL-16645-AIBlowsUpJpgImages ·
PR #8188
draft
2 open decisions
Files changed ·
Commits ·
Devin review ·
YouTrack card
Every stack in the repo gets a row, including the ones deliberately not run.
| Check | Result | Detail |
|---|---|---|
| C# build | Pass | dotnet build via the isolated wrapper, no errors. |
| C# tests | Unstable |
Green at the final commit, first attempt —
3148 passed · 13 skipped · 0 failed (total 3161) —
and green first attempt at the commit before it too, so two consecutive clean
runs to finish on. Across the whole day, though, that is 3 green out of
8 full runs; the five red ones failed in 1–2 unrelated tests each and never
the same pair twice.
BL-16722
names the mechanism for three of the six observed failures: a shared test temp-folder
collision, not this change. So the honest reading is “green, on an unreliable suite”, not “green, therefore fine” — had this run come back red I would not have treated it as blocking either, which is exactly the habit BL-16722 exists to remove. Earlier commits on this branch did go green first time, repeatedly. Read from the wrapper’s own verdict line, not the raw summary — master brought in guidance that a killed test host still prints a passing summary. The new tests on this branch cover the re-encode helper, transparency detection (sampled, exhaustive, palette and tRNS), the resize-plus-convert path, the publish
path’s tolerance of a stray transparent pixel, and an imported upload keeping its
licence.
|
| C# formatting | Pass | CSharpier, applied and verified by the pre-commit hook on every commit. |
| TypeScript typecheck | Not run | No TypeScript in the diff — see the note below about the two package.json files. |
| TypeScript lint | Not run | Same reason: nothing in the diff that ESLint reads. |
| TypeScript tests | Not run | Same reason. The Vitest suite neither builds nor reads any changed file. |
| Base integration | Clean | Master merged again — 17 commits this time (TeamCollection, AppearanceSettings, Spreadsheet, audio recording), clean, no conflicts, no overlap with our files. Across this branch’s life that is 76 commits of master folded in, with a single positional conflict early on. PR is MERGEABLE. |
src/BloomBrowserUI/package.json and src/content/package.json as
modified, but both differed only in line endings — zero content difference. They dropped out of the
staged set on their own, so the committed diff really is C#-only, and the three TypeScript rows above
are a judgment call rather than an oversight.The branch had no commits at all when this started. Everything from 9aed2457a onward came after your decisions and John’s review.
The uncommitted work, restructured into a testable
ConvertPngToJpegIfItBloatsTheJpegItReplaces
helper, with four correctness fixes folded in (see the reviewer table) and five tests.
Devin's two actionable findings. The transparency guard is the important one — without it a see-through picture would have been flattened onto a solid background and the original deleted.
ai-image*.jpg behind when the conversion is declined
A GraphicsMagick run that fails partway can leave its half-written output in the book folder. Cleaned up at our own call site, since ours is the only caller whose destination is the book folder.
The doc comment claimed more than the check delivers (see decision 1), so it now states the real guarantee and names the gap. And deleting the superseded PNG sat outside any handler — a momentarily locked file would have discarded every replacement in the commit, even though the JPEG was already written.
Added after the decisions came back — the chosen fix for decision 1. Ten extra sampled pixels, a fixed seed, and four tests. It closes a hole that was demonstrably reachable: the cutout shape converts to a JPEG 24% of the PNG’s size, so it really would have been flattened.
A severe bug the re-review turned up — see the note in the decisions
column. HasTransparency had been calling every palette-based picture opaque,
because a shortcut ran ahead of the palette check and made it dead code.
An inconsistency of my own making, caught by Devin: I had wrapped the decode and the PNG delete against a locked file aborting the whole commit, and left the cleanup a few lines above unguarded — in the branch where the conversion was declined, so there was no benefit on the table to justify the risk.
Both of John’s review points. Steve had already swapped the private ten-pixel loop
for HasAnyTransparentSampledPixel in the working tree; this commits that and
adds the credits carry-over, which measurement showed was genuinely needed.
origin/master (52 commits)
One conflict, positional rather than semantic. It also needed a one-line exemption in
build/check-csharp-ApplicationExit.sh — see the shared-tooling note in the
decisions column.
Closes the sampling gap that had come up three times. samplePixels: false at
the two call sites that gate a JPEG re-encode; the other three callers keep the sampled
default. Nine tests aimed at how a LockBits conversion actually fails. This
commit also carried the !needsResize term that caused the regression
below — see decision notes.
The severe regression fix, plus the test that was missing. Written first and confirmed
failing (Expected: <Jpeg> But was: <Png>) before the fix went in.
The developer’s edits, comment-only (verified before committing, so nothing to re-test). The transparency note now describes the exhaustive check in the code itself, including that an image only pays the full scan when it turns out to be fully opaque — any non-opaque pixel ends it early, so the transparent pictures this protects are the cheapest case.
origin/master (6 commits)
TypeScript toolbox work; clean, no overlap with our files.
TrimMetadataInImage to CopyCoreMetadata, and make it public
Both of John’s new review points, as the developer answered them in the working tree. Nothing was being trimmed at this call site — the JPEG we just produced has no metadata at all, and the point is not to lose the fields we care about. The three-argument overload keeps the old name deliberately: it really does trim.
origin/master (17 commits)
TeamCollection, AppearanceSettings, Spreadsheet and audio-recording work; clean, no overlap with our files.
Comment-only, and the whole of this run’s new work. “REUSED” loses the shouting, and “we don’t touch it” becomes “we don’t need to process it again” — the reason rather than the effect.
Your decision, made in the working tree and committed here, with your comment giving the reasoning. Also corrects the AI-editor comment that claimed the display pipeline guarded this conversion “the same way”, which this made untrue.
origin/master (11 commits)
Ligature-glyph and toolbox work, all TypeScript; clean, no overlap with our files.
Two tests and two fixes: the pin for your decision (proved failing before it passed),
the tRNS case Devin kept listing, the orphaned reserved JPEG, and the
LockBits memory framing. See the decisions column.
The one real defect found after the decisions came back — and only found because the test I wrote to prove my own diagnosis passed. See Settled opposite.
Devin’s catch on the commit above: the guard CopyCoreMetadata already
has, which that new call was missing.
Devin’s second pass on the same line: my guard covered a failed read but
not a successful one that found nothing, which is the normal case for a generated result.
Also guarded on Metadata.IsEmpty. The licence test still passes, so the case
the line exists for is unaffected.
Every reviewer reached a terminal state at the final commit. The human review and one Devin finding are open on purpose — they are the decisions on the right.
| Reviewer | State | Outcome |
|---|---|---|
| Local review light pass, done inline |
Complete | Four findings, all four fixed: (1) the JPEG was written to the PNG's name with the extension swapped, which could silently overwrite another picture already in the book; (2) the superseded PNG was never deleted, leaving the bulk we had just converted away from; (3) the loaded image was never disposed; (4) re-encoding a reused book picture could lose the credits embedded in its file, which nothing on that path re-writes. |
| Human review John Thomson |
Changes requested |
Two comments, both acted on and both replied to on Reviewable (his threads are
Reviewable-native, so they have no GitHub mirror). He asked why a second sampler
existed when HasAnyTransparentSampledPixel already did the job — fair, and
now fixed — and he questioned an unclear remark about credits, suggesting we simply
carry the metadata across. That turned out to be a real bug, and is fixed. The threads
stay open until he flips his own disposition; that is his to do, not mine.
|
| Devin 18 rounds |
Complete |
Eighteen rounds across seven days. At the final commit the four bugs are all still marked resolved and nothing new was raised as a bug; the two informational items it added in the last two rounds are decisions 1 and 3 opposite. Rounds in order: Round 1 (382422a72): one severe bug — transparency loss — and one Investigate flag (an unhandled decode failure would abort the whole commit), plus 3 informational. Both actionable ones fixed and their threads replied to and resolved.Round 2 (9f5b0b1a5): marked the severe bug resolved; raised the corner-heuristic flag (left open — decision 1) and one informational, the stray file, which is fixed. Round 3 (414cade60): restated the corner heuristic as a Bug (same thread), raised the empty-slot scope question (left open — decision 2), and two informational, of which I fixed the unguarded delete and assessed the other (a speculative image-cache interaction it calls pre-existing) as not an issue for this PR. Round 4 (90ba50f50): the same Bug plus the two Investigate flags already open above, and re-raises of findings already fixed — including the decode failure, whose text still claims there is no try/catch where there now plainly is one. Nothing new and actionable, so this is where the fix–push–re-review loop stopped, at its cap of four rounds. The remaining nine informational items were assessed and left: the JPEG-marker coverage (decision 5) and an image-cache/name-reuse observation both concern pre-existing shared code and fail in the safe direction, and a check-then-write window on the reserved filename isn’t reachable because the commit handler is synchronised.Rounds 5–7 (d1d02a4aa … f1a596fc3, after the decisions came back): the chosen transparency fix drew a follow-up that earned its keep — a severe palette bug (see the card above), fixed in 93e89582f, plus a missing guard on one cleanup delete that was my own inconsistency, fixed in f1a596fc3. It also re-argued the ten-pixel cap, a decided matter, which was recorded as such rather than reopened. Round 7 came back clean. Round 8 (8cc32a65b): two of its three bugs carry older head shas and are marked resolved — carried-forward findings, not new. It also confirmed the palette reordering is safe for the other callers, having checked all three. Rounds 9–11 (6daa07f35 … 58dcde748): found the severe publish-size regression described opposite, then confirmed it fixed, then came back clean. Round 12 (92c176063): bugs still all resolved, but two new Investigate items — one closed by measurement (the empty metadata write is a genuine no-op), one open as decision 1. Round 13 (dc9ee8483): clean — no new findings, and the four bugs it still lists all carry older head shas, so they are this branch’s fixed history rather than live findings. Round 14 (161c8c68b): bugs unchanged and still resolved, and three new informational items, every one of which was worth something — the orphaned reserved JPEG (fixed), the LockBits memory framing (comment corrected), and the credits question that is now decision 1, the only one of the three I could not settle myself and only because the fix belongs outside this PR. A good round for a bot that had gone quiet. Round 15 (6ca5bb9de): two more informational items — the JPEG→JPEG publish pass, now BL-16721, and a scope observation about repeat AI edits, assessed as consistent with the decided scope. Round 16 (688cb7d0a): the round that stopped this branch being promoted. Four new items, and the first is the open decision opposite — the widened default suppressing forced background removal, which I verified against master rather than taking on trust. It also caught the missing metadata guard (fixed in 64a16b4c9) and asked a fair cross-platform question about my tRNS test (refuted: this suite is Windows-only). Sixteen
rounds in, it is still finding things, and this one it found in code I had written an hour
earlier.Round 17 (64a16b4c9): one more informational item, and again on my own new line — the guard covered a failed metadata read but not an empty one. Fixed in dae14c121. Round 18 (dae14c121, the final commit): outcome in the verdict column. One correction to an earlier report.
I wrote that every Investigate item Devin still lists “has a documented outcome on
its own thread”. That held for the mirrored Bug and Investigate threads, but not
for all nine items it flags for investigation: the |
| CodeRabbit | Not active |
.coderabbit.yml in this repo sets auto_review.enabled: false, so it never
reviews on its own and posted nothing. I did not summon it manually — that config looks like a
deliberate team choice, not an accident.
|
| CI | Pass |
The single pr-automation check passed in 31s. Worth knowing:
that workflow runs no tests — its only job is to open the Devin review URL. So the C# suite
above is the whole test gate for this PR, which is why it was run in full at every commit.
|
HasTransparency_SameImageTwice_GivesTheSameAnswer exists to hold).
“Random” reads fine as “scattered”, but it could also be read as
“varies run to run”, which would be wrong. Your comment, your call — a
one-word change if you want it.commit_sha before believing a status, which is the same trap the skill warns
about for inferring Devin's state from GitHub signals.
Devin found that tryJpegConversion had picked up a !needsResize
term. That flag gates two conversion attempts, and the earlier one already required
!needsResize, so folding it in as well left the attempt below the
resize reachable only for BMP/TIFF. A large photographic PNG was resized and then written
straight back out as a PNG.
That lands where it hurts most. BloomPubMaker and EpubMaker both
pass max dimensions, so needsResize is true for exactly the large photos where
the JPEG saving is biggest. Every ebook and BloomPUB containing one would have grown several
times over — the opposite of what this ticket is for.
The term arrived in my commit 6aa6d0250. It was
in the working-tree diff I picked up alongside the samplePixels work, and I
committed it without scrutinising it or mentioning it in the message. Devin was right that
nothing explained it; master has no such term. Fixed in
b2271b99b and
documented on the PR.
The missing test was the real gap, and it is the durable part of the fix. The existing
AdjustImageForDisplay_PhotoButPNGFile_ConvertsToJpeg uses a 118×154 image,
which never resizes, so it could not see this. The new one asks for a size smaller than the
source — the way the publication code calls in — and asserts the result is still
a JPEG after checking it really was shrunk. I wrote it first and confirmed it failed
(Expected: <Jpeg> But was: <Png>) before applying the fix.
The sampling gap that had come up three times is closed, taking the
recommended option: HasTransparency gained a samplePixels
parameter, set to false at the two call sites that gate a JPEG re-encode, where
it now reads every pixel’s alpha byte through LockBits. The other three
callers keep the sampled default, so their behaviour is untouched. That
thread is resolved.
The timing figures on it are corrected too. Steve pointed out I had measured the wrong maximum image size, so the real worst case is:
| Image | Sampled | Exhaustive |
|---|---|---|
| 3840×2800 (max, landscape) | 0.1 ms | 9.9 ms |
| 2800×3840 (max, portrait) | 0.1 ms | 9.5 ms |
| 2800×2800 (what I first quoted) | 0.1 ms | 7.0 ms |
Two items. The first is new, is ours, and is the reason this branch went back to preflight instead of being promoted.
From Devin, both verified by measurement · publish-size thread · background-removal thread
Read this one first, because it may unmake the choice you already made. You chose “revert just the publish call site to sampled”, and the comment in 632e5b1a7 reasons that the sampling “will usually miss [stray pixels] and allow the image to be converted to the smaller JPEG format”. That describes master’s corner-only sampling. But the sampled mode itself was widened underneath it in d1d02a4aa — corner block plus ~100 scattered points. So reverting the call did not restore the behaviour; it restored a call to a predicate that now answers differently.
Measured. A photograph with a 30×30 knock-out in the middle, put
through AdjustImageForDisplay the way the publication code calls in:
| corner-only check (master’s default) | misses it — the first 15×15 is all opaque |
| widened default (this branch) | detects it |
| published result | PNG, where master produced a JPEG |
Both consequences are real, and they pull opposite ways: the knock-out now survives publication (master flattened it — an improvement), and the book ships larger (which is the bloat this ticket exists to remove). It applies to every book, not just AI-edited ones, from a branch whose title is about the AI image editor.
And it exposes a limit in my own test.
AdjustImageForDisplay_PhotoPngWithOneStrayTransparentPixel_StillConvertsToJpeg
hunts for a pixel the sampler misses, so by construction it can only pin the
direction where conversion still happens. It cannot see this case, and I should not have let
it read as though the publish path were covered.
The second consumer, same root cause:
RuntimeImageProcessor.MakePngBackgroundTransparent opens with
if (ImageUtils.HasTransparency(originalImage.Image)) return …Clone() —
the default sampling mode, and when it answers true the background is left alone.
On origin/master that default is corner-only, a 15×15
block. This branch widened it in d1d02a4aa to fold in the
~100 jittered points. So for every image that is opaque in its corners but has one sampled
interior pixel below alpha 255, the background now silently isn’t removed.
What makes it a decision rather than a shrug: on the ImageTransparencyMode.Force
path the caller has explicitly asked for the white background to go, and it now
quietly doesn’t. No error, no log — the image comes back as it went in. Nothing
in the suite covers that path, which is why six full runs never mentioned it.
This is the third time this branch has widened a shared predicate and hit a consumer
nobody was looking at. !needsResize stopped resized photos converting at
all; the exhaustive scan blocked conversion for a single stray pixel; now the scattered sample
suppresses background removal. Each of the first two looked obviously right at the time, which
is exactly why I have not quietly picked a fix for this one.
One choice settles both. Options:
(1, recommended — and this is a change of mind) narrow the widening
itself: keep the scattered sample for the AI import path, which is what asked for it, and
let every other caller have master’s corner-only default. Before running the measurement
I had this ranked last for blast radius; the measurement moved it, because it is the only
option that confines a global behaviour change to the scope named in the PR title.
(2) Accept both consequences deliberately — better transparency
fidelity, larger books, background removal quietly declining on some images — and record
that in the code so nobody rediscovers it as a bug. If you take this, Force should
at least log rather than silently no-op. (3) Fix them one at a time: give just
the background-removal caller a corner-only test, and separately decide the publish path. More
commits, and it leaves BookThumbNailer:283 — the third consumer —
unexamined.
What I still have not measured: how often real book images carry transparency away from the corner. My measurement proves the mechanism on a constructed image; it says nothing about frequency, and frequency is what decides whether option 2 is fine or expensive. A look at a handful of real collections would settle it, and I would not guess.
From John, and independently from Devin · open thread
Unchanged and still waiting. The conversion runs only for a freshly generated or uploaded
result; its stated justification was that re-encoding a reused image would lose its
embedded credits, which is exactly what we fixed. So the exclusion rests on scope alone.
Devin’s addition stands: resizeIfNeeded:false just above rests on the same
obsolete reasoning.
Found by this run · kept here because it colours how you read the test row, not because it needs an answer
AGENTS.md says the suite is expected green through the wrapper. Across seven
full runs today it was green twice, with 1–2 failures each time
otherwise, spread over six different and unrelated tests — AI editor,
epub audio, credits page, TeamCollection status, BloomPUB compression, device xmatter.
Correction to the first version of this item: I wrote that every failure shared one signature. It does not — I had read the exception messages from two runs and the test names from the others. Three of the six share it; the rest have separate causes, listed below. The mechanism still holds for those three, and they are filed as BL-16722.
Those three are each a DirectoryNotFoundException on a path inside the
run’s own isolated temp root, raised from inside RobustFile after its
retries —
…\BookTests\book\book.htm,
…\BookTests\book\epub-thumbnail.png,
…\ePUB_export\0\…\lang-qr-code.png. A directory that should
exist, vanishing mid-test.
The mechanism. BookTestsBase.Setup() is a [SetUp]
— so it runs before every test — and does
new TemporaryFolder(GetTestFolderName()), which returns the literal
"BookTests", then nests "book" inside it.
TemporaryFolder deletes an existing folder of that name before creating
it. Eight fixtures derive from BookTestsBase and only
BookThumbnailerTests overrides the name, so they all share one path. Any work
still touching BookTests\book when the next test’s setup wipes it gets
exactly the exception above. That fits the symptoms precisely: intermittent, timing
dependent, and clustered in the publishing fixtures — the ones most likely to have
something outstanding, which is the same hazard behind this repo’s
“tests retire their BloomServers rather than disposing them” rule.
ePUB_export is worse: it is a production constant
(EpubMaker.kEPUBExportFolder), so every epub test shares one staging root.
The correlation is what makes this more than a guess: all three of those failures are in
fixtures that inherit the shared name (ExportEpubTests twice,
BloomPubMakerTests once).
The other three failures are not this bug, and I am not claiming them:
InCreditsPage_LicenseUrlAndISBN_AreRemoved could not find
customBookStyles.css under the shared output\browser — a
build artefact, not a temp folder; GetStatus_BuildNeeded… in
RabAppProjectTests (which does not inherit the name) reported RAB
importing 1 book where 2 were expected; and my own
ConvertPngToJpeg_UndecodableFile… lost a file from its fixture’s
own per-test folder, which remains unexplained.
Filed as
BL-16722
(Bug, Bloom 6.6, Incoming) rather than fixed here — it is test-infrastructure work
across nine fixtures, unrelated to this ticket. The fix has a precedent in the tree:
BookThumbnailerTests and BookCompressorTests already override
GetTestFolderName(), and deriving it from GetType().Name in the base
class would make the problem structurally impossible. Worth doing: a suite that goes red two
runs in three for reasons unrelated to your change trains everyone to re-run until green,
which is how a real regression gets waved through — and AGENTS.md tells
people the suite is expected green, so they bisect their own work instead. Which is what I
did, for about half an hour.
Two things I got wrong on the way here, both worth knowing. I first blamed
my own orphan-cleanup fix, on the strength of a single green run with it reverted; the next
run failed elsewhere with the fix restored, so that was wrong. And two of the red runs
overlapped a git worktree add/remove I started myself, so I was
polluting my own measurements for part of it. The root cause above does not depend on either
of those.
CopyCoreMetadata’s
read-failure guard (64a16b4c9), then that the guard still
let an empty model be written — the normal case for a generated result
— which could strip what a verbatim copy had preserved
(dae14c121). Worth noticing about my own work: the line
exists to stop credits being lost, and both defects were ways it could cause the loss.
The licence test passing after each change is what shows the real case still works.CopyCoreMetadata skips when libpalaso couldn’t
read it. So for a source with unparseable tags my fix could have stripped
metadata that a verbatim copy had preserved — a line whose whole purpose is not
losing credits, causing the loss. Guarded in 64a16b4c9.
No test: reaching it needs bytes that decode as an image but whose tags fail to parse,
which I could not construct reliably, and the existing undecodable-file test fails
earlier at the decode. Said so in the commit rather than implying it is covered.tRNS test on Linux — Devin asked whether it
passes on both CI platforms. There is no second platform: BloomTests.csproj
targets net8.0-windows, so libgdiplus never sees it. Its second point was
right though, and is why the test exists: if GDI+ ever stopped surfacing
tRNS, the AI editor would flatten such an image and delete the original.
Refuted and resolved.PageEditingModel.ChangePicture does
it in the next statement), so only the path this PR added lost anything — and
what it lost was not the credits, because libpalaso writes those twice, as PNG
tEXt keys and as XMP. GraphicsMagick keeps the
tEXt and drops the XMP, where the licence alone lives.
So the test I wrote to prove the bug passed; adding one licence assertion
turned it red. The real symptom is nastier than the one I described: an uploaded
photo arrived with its copyright and creator intact and its Creative Commons licence
silently reduced to NullLicense — healthy-looking, and wrong.
Fixed with the call the other caller already makes.
Bearing on the item below: the reuse exclusion’s credits
justification is now definitively gone, since metadata is re-attached whichever path
processing took.isJpeg is already computed nine lines above the condition, so
the fix is plausibly one term — and records what I did not establish: the
real per-publish cost, and how often the 50%-smaller test actually succeeds. The fix is
not free, so that is worth knowing first. Thread resolved.AdjustImageForDisplay is back to sampled detection and the AI editor keeps
the exhaustive scan, committed as 632e5b1a7 with your
reasoning in the comment. The part I could not supply is the part that decided it: this
code sampled for years without a complaint, which beats a measurement over a handful of
images. Recorded on the thread and resolved. I then pinned it with a test — the
revert broke nothing, which is exactly how the exhaustive scan reached that call
site unnoticed in the first place, and how !needsResize got in before it.
Also fixed one comment the change made untrue.tRNS chunk
— Devin has listed this for several rounds and it had no thread and no recorded
outcome, so I measured it: GDI+ does hand us such a PNG with an alpha channel, so
HasTransparency catches it and nothing gets flattened. Same shape as the
palette bug, which is why it was worth checking rather than assuming; now pinned by a
test with a 198-byte generated image.catch could walk
away from a JPEG GraphicsMagick had already written (the throw can come from disposing
the PalasoImage, after the conversion succeeded). Real, and now cleaned up
there too.IsEmpty=true,
NullLicense), and the ask token is already on the source. No
change; resolved.
Why the Reviewable count kept climbing back. Every thread this workflow
posts to GitHub — each mirrored Devin finding, and each PR-level comment, which lands
in the -top discussion — is mirrored into Reviewable as a discussion that
lists me as owing a disposition, and resolving it on GitHub does not set that.
So the check would read “4 discussions left” while GitHub showed nothing
unresolved. I now set those dispositions in the same step as posting:
satisfied for anything fixed or refuted and closed, discussing for
the one item deliberately left open. As of the final commit only that open item
(decision 1) still shows against my name. John’s own threads stay his to flip.