Skip to content

feat(decode): read strip-based embedded previews and colour-manage thumbnails - #22

Merged
justin13888 merged 1 commit into
masterfrom
feat/read-only-bulk-view
Sep 21, 2026
Merged

justin13888 merged 1 commit into
masterfrom
feat/read-only-bulk-view

Conversation

@justin13888

Copy link
Copy Markdown
Collaborator

Second of the stacked PRs. Targets fix/surface-failures-and-honest-status
(#21), not master
— review that one first.

The headline

Opening ~/Downloads/sample-1 showed 18 grey boxes. Every one of those
files carried a multi-megapixel JPEG preview that was simply never looked for.

Previews found
Before 0 / 18
After 18 / 18

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 = 7 whose bitstream is located by StripOffsets /
StripByteCounts (0x0111/0x0117).

rawshift_image::tiff is a public module, so both conventions are read in
focale-core without forking upstream. rawshift's own extractor is kept as a
fallback 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_file rejects outright — which is most of
them 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_GAMUT the shader uses, so both surfaces answer to one
definition 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, plus num-traits
(MIT/Apache-2.0) and pxfm (BSD-3/Apache-2.0). All AGPL-compatible,
[HARD-LICENSE] checked. Added to focale-app only: it is display-side and
must 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.

  • Decodes are bounded in flight (3), not left to the worker count.
  • Ordered nearest-first, so tiles under the user's eyes resolve first
    rather than whatever sorts first by file name.
  • Textures are evicted by distance from the open image — they were never
    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-app tests 20 → 33, incl. all 8 EXIF orientations, ICC fallback
    paths, and a P3→sRGB conversion check.
  • Decode determinism golden unchanged; export bytes unchanged.

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 axis
survives, which is the real invariant for two D65 profiles.

…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
@justin13888
justin13888 merged commit 27d48c8 into master Sep 21, 2026
7 checks passed
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.

1 participant