NC Imago · Docs

Reading a raw's preview without reading the raw

A grid thumbnail and a loupe preview of an unedited raw both show the camera's embedded JPEG, not a decode of the sensor data. That JPEG lives near the start of the file, but the rest of the file is the sensor data — for a 24 MP Fuji RAF, 50 of 56 MB. Until this change the preview loader called std::fs::read on the whole file and threw the sensor data away, so every preview paid for bytes nobody looked at.

That is free on a local SSD and expensive on a network share, which is where photographers keep libraries. Measured on one real file (DSCF9492.RAF, 56.5 MB) over a GVFS/SMB share on Wi-Fi:

read time
whole file 59 277 632 B (100 %) ~10 s
header + preview 5 857 257 B (9.9 %) 0.96 s

How it works

  1. read_prefix reads the first PREVIEW_PREFIX (4 KiB); every format whose preview is locatable from the header keeps its pointer well inside that.
  2. For Fuji RAF the header's two big-endian u32s at 84 (offset) and 88 (length) are the same fields imago_raw's RAF reader uses. When they declare more than the prefix holds, the prefix is re-read once at exactly that extent: imago_raw::bounded_preview reports the range its buffer covers (slice clamps to b.len()), so a short prefix yields a plausible but truncated range — a real 4 KiB prefix reports 3 948 bytes where the file holds 5.6 MB.
  3. embedded_preview_from_range checks the window really starts with a JPEG SOI and trims it at the last EOI marker, exactly as embedded_preview trims the whole-file answer.
  4. When the prefix already covers the range — the usual case, since it was grown to it — the window is a slice of the prefix. Reading it again would double a 5.6 MB preview.

Formats whose preview pointer is not in the header — a Canon CR3's boxes, a TIFF raw whose preview tag sits in an IFD the prefix cuts short — are planned instead (next section but one), and what the planner cannot describe keeps the whole-file path, so nothing regresses: preview_window returns None and the loader reads the file as before. A pointer that lies, a short read, a plan that does not fit or a file that changes underneath us ends in the same fallback — the user's preview is never lost to a damaged header.

IMAGO_PROFILE=1 prints what each preview cost:

[profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109)

What the header cannot point at: the plan

A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can keep its preview tag in an IFD the header route's prefix cuts short. Neither has a field step 2 above can read, so preview_window asks imago_raw::sparse::preview_plan — the module a professional photo organizer-catalog import probes with — which ranges embedded_preview reads, and reads exactly those:

Measured over the CC0 corpus (111 raws, cargo xtask corpus --download): 52 files take the plan route, reading a median 7.8 % of their bytes (0.9 %–27.7 %) — by format RW2 3.3 %, Leica RWL 4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % — while the 21 RAFs stay on the header route (median 4.5 %). The remaining 38 read whole: NEF, NRW, ORF, PEF and Samsung SRW because the planner does not describe them (the first four say so; an SRW plans to the whole file, and the 60 % budget then keeps the whole-file read), and the ARWs, CR2s, CR3s and DNGs that declared wide-gamut RGB (these take the plan route since #1440; the counts here are from before) or whose plan wants more than that budget. Across the whole corpus the plan handed on the same preview as the whole file, every time — and wrote it down: cargo test -p imago-raw --release --test sparse_corpus -- --nocapture prints these numbers. IMAGO_PROFILE=1 says which route answered:

[profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441)

A Canon CR3's preview is a JPEG track instead (issue #832 measured 3 MB of one R5 Mark II file, ~15 %), and Canon is the largest bucket in gaps.md's ranking (33 % of photographers), so before this change every unedited CR3 thumbnail paid for the whole file.

The grid thumbnail is the embedded JPEG too

A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the sensor behind it. The grid showed that JPEG first in any case and then queued a decode of the whole raw to replace it — for a folder of 149 Fuji RAFs that is 8.3 GB of traffic, 45 s a photo at 1.2 MB/s, and a queue that delayed the render the user was actually waiting for (measured: the first loupe render of a cold photo finished 174 s after the click, most of it spent behind thumbnails).

Measured on the real folder: idle for a minute, the app read 12 previews (26.9 MB) and decoded zero raws for thumbnails, against 638 MB of whole-file reads for the same 12 photos before. The render queue stayed empty.

The smallest JPEG that covers the box

Many TIFF-based raws keep more than one embedded JPEG. A Sony A7R VI ARW holds a 1616 × 1080 one (0.3 MB) beside the 9984 × 6656 one (5.6 MB), and Nikon NEF and Panasonic RW2 files have mid-sized copies too. The loupe stand-in and the grid thumbnail (embedded_preview_srgb) decode the smallest JPEG whose long edge is at least the size asked for (imago_raw::embedded_preview_covering). They take the largest one when no other JPEG is big enough. The plan route picks the same way (preview_window takes the size asked for), so a raw read through the plan gets the JPEG the whole file would give. A smaller JPEG only counts as a copy of the picture when its aspect ratio is within 1 % of the largest one's and it has the same number of colour components. That rules out letterboxed thumbnails of another shape and the grey masks an iPhone ProRAW DNG keeps at the picture's shape. Sources for editing (an unsupported raw's JPEG, load_embedded_preview) and the camera-look fit still use the largest JPEG, so their geometry and colour do not depend on the size level.

Tests

A network share is still slower than a local disk — the preview itself is 5.6 MB — and the sensor data still has to come over the wire for the developed picture, which is a separate path (fs_pair_loader, the full decode). This change removes the preview's share of it, nothing more.