feat(decode): read strip-based embedded previews and colour-manage thumbnails - #22
Merged
Merged
Conversation
…umbnails Opening a folder of modern DNGs showed 18 grey boxes. Every one of them carried a multi-megapixel JPEG preview that was simply never looked for. rawshift 0.1.1 reads only `JPEGInterchangeFormat` / `-Length` (0x0201/0x0202), the TIFF/EP thumbnail convention. Modern DNG writers — Adobe, and Apple for ProRAW — use the other form the DNG spec allows: a full IFD with `Compression = 7` whose bitstream is located by `StripOffsets` / `StripByteCounts` (0x0111/0x0117). `rawshift_image::tiff` is a public module, so both conventions can be read here without forking upstream; rawshift's own extractor stays as a fallback for formats this reader does not cover. On the 18-file ProRAW corpus: 0 previews found before, 18 after. This matters beyond thumbnails. Preview extraction never decodes raw pixels, so it works on files `decode_file` rejects outright — which is most of them while decode support is still growing. A directory stays browsable and cullable no matter how far rawshift has got. Colour. A preview is someone else's rendering and arrives tagged with its own space. Apple writes Display P3, and treating those bytes as sRGB showed every thumbnail oversaturated — the filmstrip disagreeing with the viewport about the same file. Thumbnails now convert through the preview's embedded ICC to the same `viewport::DISPLAY_GAMUT` the shader uses. Untagged input is assumed sRGB; an unparseable profile falls back to sRGB rather than dropping the image, because slightly wrong colour beats a blank tile. Adds `moxcms` (BSD-3-Clause OR Apache-2.0, with num-traits and pxfm, all AGPL-compatible per HARD-LICENSE) as a `focale-app` dependency only. It is display-side and must never reach the export path, which generates its own output profiles in `focale-export::icc`. Orientation. Previews are stored in sensor orientation: 16 of the 18 sample files are orientation 6, so without rotation nearly every thumbnail displayed on its side. IFD0's orientation is returned from the same TIFF parse and applied before display. Size. These previews are not small — six of the corpus are 8064x6048, about 145 MB of RGB once decoded. Letting every worker take one at once is over a gigabyte of transient allocation for a filmstrip. Decodes are now bounded in flight, ordered nearest-first so the tiles under the user's eyes resolve first, and textures are evicted by distance from the open image. A file with no embedded preview is tracked separately from one that failed to load. It is not broken and must not be badged as though it were; it shows "no preview" instead. focale-app tests 20 -> 33. Export bytes unchanged.
Base automatically changed from
fix/surface-failures-and-honest-status
to
master
September 21, 2026 02:23
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.
Second of the stacked PRs. Targets
fix/surface-failures-and-honest-status(#21), not
master— review that one first.The headline
Opening
~/Downloads/sample-1showed 18 grey boxes. Every one of thosefiles carried a multi-megapixel JPEG preview that was simply never looked for.
Why they were invisible
rawshift 0.1.1 reads only
JPEGInterchangeFormat/-Length(0x0201/0x0202),the TIFF/EP thumbnail convention. Modern DNG writers — Adobe, and Apple for
ProRAW — use the other form the DNG spec allows: a full IFD with
Compression = 7whose bitstream is located byStripOffsets/StripByteCounts(0x0111/0x0117).rawshift_image::tiffis a public module, so both conventions are read infocale-corewithout forking upstream. rawshift's own extractor is kept as afallback for formats this reader does not cover (Sony's larger MakerNote
preview, for one).
This matters beyond thumbnails. Preview extraction never decodes raw
pixels, so it works on files
decode_filerejects outright — which is most ofthem while decode support grows. A directory stays browsable and cullable no
matter how far rawshift has got. That is the read-only layer doing its job.
Colour management
A preview is someone else's rendering and arrives tagged with its own space.
Apple writes Display P3. Treating those bytes as sRGB showed every
thumbnail oversaturated — the filmstrip disagreeing with the viewport about the
same file.
Thumbnails now convert through the embedded ICC to the same
viewport::DISPLAY_GAMUTthe shader uses, so both surfaces answer to onedefinition of "what this display is". Untagged input is assumed sRGB; an
unparseable profile falls back to sRGB rather than dropping the image, because
slightly wrong colour beats a blank tile.
New dependency
moxcms— BSD-3-Clause OR Apache-2.0, plusnum-traits(MIT/Apache-2.0) and
pxfm(BSD-3/Apache-2.0). All AGPL-compatible,[HARD-LICENSE]checked. Added tofocale-apponly: it is display-side andmust never reach the export path, which generates its own profiles in
focale-export::icc.Orientation
Previews are stored in sensor orientation. 16 of the 18 sample files are
orientation 6, so without rotation nearly every thumbnail displayed on its
side. IFD0's orientation now comes back from the same TIFF parse (no second
read) and is applied before display.
Size — this is the part that bites
These previews are not small. Six of the corpus are 8064×6048, ~145 MB of RGB
once decoded. Letting every worker take one at once is over a gigabyte of
transient allocation for a filmstrip.
rather than whatever sorts first by file name.
evicted at all before, which was unbounded GPU memory on a large directory.
Honesty, continued from #21
A file with no embedded preview is tracked separately from one that
failed to load. It is not broken and must not be badged as though it were;
it reads "no preview". Three states now look different: broken, no preview, and
not arrived yet.
Verification
cargo fmt --check,clippy -D warnings(on 1.98, matching CI),cargo test --workspace— all green.focale-apptests 20 → 33, incl. all 8 EXIF orientations, ICC fallbackpaths, and a P3→sRGB conversion check.
A note on the P3 test
My first version asserted P3 red converts to something other than sRGB red. It
failed — correctly. P3's primaries sit outside sRGB, so a
relative-colorimetric transform clips them straight back onto sRGB's primaries
and a pure primary looks like a no-op. The test now probes a mixed in-gamut
colour (
[200,100,50] → [214,92,31]) and separately asserts the neutral axissurvives, which is the real invariant for two D65 profiles.