Preflight — an AI-generated PNG that bloats the JPEG it replaces

BL-16645 · BloomDesktop · branch BL-16645-AIBlowsUpJpgImages · PR #8188 draft 2 open decisions
Files changed · Commits · Devin review · YouTrack card

Quality gate

Every stack in the repo gets a row, including the ones deliberately not run.

CheckResultDetail
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.
  • Git reported 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.

What changed this run

The branch had no commits at all when this started. Everything from 9aed2457a onward came after your decisions and John’s review.

382422a72 Re-encode an AI PNG that bloats the JPEG it replaces

The uncommitted work, restructured into a testable ConvertPngToJpegIfItBloatsTheJpegItReplaces helper, with four correctness fixes folded in (see the reviewer table) and five tests.

9f5b0b1a5 Never re-encode a transparent PNG, and survive an undecodable one

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.

414cade60 Don't leave a stray 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.

90ba50f50 Say what the transparency guard really promises, and don't let the cleanup delete abort a commit

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.

d1d02a4aa Also check a few scattered pixels for transparency, not just the corner

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.

93e89582f Ask an indexed image’s palette about transparency before assuming it has none

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.

f1a596fc3 Guard the declined-conversion cleanup too, not just the delete after it

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.

9aed2457a Reuse the existing sampler, and carry credits across the re-encode

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.

8cc32a65b Merge 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.

6aa6d0250 Check every pixel’s alpha through LockBits when the caller needs certainty

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.

6daa07f35 Merge origin/master (XLF fix)

Clean, no conflicts, no overlap with our files.

b2271b99b Don’t let needing a resize suppress the PNG-to-JPEG conversion

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.

005903b07 Tighten the comments on the PNG-to-JPEG re-encode

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.

58dcde748 Merge origin/master (6 commits)

TypeScript toolbox work; clean, no overlap with our files.

947871e8a Rename 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.

92c176063 Merge origin/master (17 commits)

TeamCollection, AppearanceSettings, Spreadsheet and audio-recording work; clean, no overlap with our files.

dc9ee8483 Soften the wording in two comments about reused images

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.

632e5b1a7 Let the display pipeline go back to sampling for transparency

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.

161c8c68b Merge origin/master (11 commits)

Ligature-glyph and toolbox work, all TypeScript; clean, no overlap with our files.

6ca5bb9de Pin the publish path’s stray-pixel tolerance, and close two review gaps

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.

688cb7d0a Re-attach an imported image’s metadata, so an upload keeps its licence

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.

64a16b4c9 Don’t re-attach metadata we failed to read in the first place

Devin’s catch on the commit above: the guard CopyCoreMetadata already has, which that new call was missing.

dae14c121 Don’t write an empty metadata model over an imported image

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.

Reviewer outcomes

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.

ReviewerStateOutcome
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 tRNS one and the credits one had no thread and no recorded outcome at all. Both do now — the first measured and pinned by a test, the second measured and raised as decision 1.

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.

Session notes

  • The local review was done inline rather than by a sub-agent: this session's configuration forbids dispatching the Agent tool unless you ask for it. That is the fallback the preflight skill specifies, and it is why the reviewer row above reads "done inline".
  • The card already carried a PR link — but to #8146, the earlier piece of work on this same ticket. Since #8188 is a genuinely different PR rather than a duplicate link, I posted it as a new comment.
  • The test ideas went on as a round-2 comment instead of overwriting the existing one. Testers had already worked through those notes — that is how this bug was found — so overwriting would have thrown away the record of what they had already checked. The new comment says plainly what needs re-testing and what does not.
  • I also kept the new notes short. There is a request on the card asking for these write-ups to be trimmed ("I usually have to delete about half of it").
  • The QA notes on the card were rewritten, not appended to. They claimed Bloom “checks every pixel” and that publishing uses “the same decision” — which your change reversed for the publish path. A tester following the old text would have gone looking for behavior that no longer exists, so the two paths are now described separately and the flip is called out under “watch these especially”.
  • One wording point I left alone deliberately. Your new comment calls the scattered sampling “random”; it is actually deterministic (a fixed seed, which is what 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.
  • My own Devin poll nearly reported the wrong commit’s verdict. It read the newest job in the list, and right after a push that is still the previous commit’s completed job — so it announced “terminal” within seconds of pushing. Caught because the timing was implausible. It now matches on commit_sha before believing a status, which is the same trap the skill warns about for inferring Devin's state from GitHub signals.
  • The transparency hazard was verified, not assumed: running GraphicsMagick over the transparent test image produces a JPEG 23% of the PNG's size, comfortably inside the "worth keeping" threshold. So it really would have converted and destroyed the transparency, and the new test guarding it cannot pass vacuously.

The run caught a severe regression — mine

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.

Also done this run

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:

ImageSampledExhaustive
3840×2800 (max, landscape)0.1 ms9.9 ms
2800×3840 (max, portrait)0.1 ms9.5 ms
2800×2800 (what I first quoted)0.1 ms7.0 ms

Your decisions

Two items. The first is new, is ours, and is the reason this branch went back to preflight instead of being promoted.

The transparency default got wider, and all three of its consumers inherited that — including one you already decided about

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 resultPNG, 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.

Reused book images: the reason for excluding them is gone

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.

The full C# suite is only intermittently green — filed as BL-16722, nothing to decide

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.

Settled

  • Two passes on one line of mine. Devin reviewed the metadata re-attach twice and was right twice: first that it lacked 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.
  • The metadata guard I was missing — Devin noticed that the re-attach in 688cb7d0a wrote the source’s metadata unconditionally, while 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.
  • The 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.
  • The imported upload’s licence — fixed in 688cb7d0a, and my diagnosis of it was wrong twice. I reported that a GraphicsMagick import resize loses the credits, and that this was pre-existing breakage in shared import code. Neither held. Every other caller re-attaches the metadata afterwards (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.
  • The wasted GraphicsMagick pass on every published JPEG — you chose a ticket of its own, filed as BL-16721 (Bug, Bloom 6.6, Incoming). It carries the diagnosis rather than the symptom — including that 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.
  • The stray alpha pixel — you chose option 2, in the working tree: 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.
  • A truecolour PNG whose transparency is in a 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.
  • The orphaned reserved JPEG — the 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.
  • The empty metadata write — Devin asked whether copying metadata from a source with none leaves the JPEG asserting a default licence. Measured: the raw conversion and the copied one are identical (IsEmpty=true, NullLicense), and the ask token is already on the source. No change; resolved.
  • The scan’s cost — kept, on your decision. Resolved.
  • John’s rename and accessibility points — both committed in 947871e8a, each with a reply saying what changed. His threads await only his own disposition.
  • The transparency sampling gap, empty/placeholder slots, and the severe publish-size regression — all closed in earlier rounds.

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.