mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
# Conflicts: # crates/engine/src/files.rs # docs/raw-preview-reads.md # docs/whats-new.md
163 lines
11 KiB
Markdown
163 lines
11 KiB
Markdown
# 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 `u32`s at **84** (offset) and **88** (length) are the
|
||
same fields `lightcraft_raw`'s RAF reader uses. When they declare more than the prefix holds, the
|
||
prefix is re-read once at exactly that extent: `lightcraft_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.
|
||
|
||
`LIGHTCRAFT_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 `lightcraft_raw::sparse::preview_plan` — the module the
|
||
Lightroom-catalog import probes with — which ranges `embedded_preview` reads, and reads exactly
|
||
those:
|
||
|
||
- the buffer is the file's length with only the planned ranges filled in; every other byte stays zero
|
||
and its pages are never written. Its cost is the bytes read — on macOS a freed buffer of that size
|
||
may stay in the allocator's cache instead of going back to the OS (measured on a 38k-photo import,
|
||
[#832](https://github.com/storytold/lightcraft/issues/832)) — and the loader's memory gate counts
|
||
the whole file either way;
|
||
- the module's own guarantee is what this path needs, and its corpus test asserts it: with the
|
||
skipped bytes `0` and again `0xFF`, what a partly loaded buffer yields is what the whole file
|
||
yields. A second corpus pass now asks `preview_plan` for **every** file, including the ones whose
|
||
header probe reads samples and whose header plan is `Whole`, which is where that plan was untested
|
||
before;
|
||
- the quick path must be able to show what the whole-file path would. Two container facts are not in
|
||
the JPEG. A preview colour space of **Adobe RGB** is read from the planned buffer and handed on
|
||
(`PreviewRead::adobe_rgb`), and the quick decode applies it as `decode_raw_preview` does (issue
|
||
#1440: a Sony α1 writes Adobe RGB previews, which until then sent every one of them to the
|
||
whole-file read — 70 MB for an 8 MB JPEG); a container that says sRGB needs nothing, since that is
|
||
what the decode assumes anyway. The file's own EXIF orientation, when the preview JPEG carries none
|
||
of its own and the bounded header cannot see it, still declines and the whole file keeps the
|
||
preview;
|
||
- a format the planner cannot describe — NEF, ORF and PEF probe from samples, CRW/MRW/X3F are found
|
||
by scanning — a plan that does not fit the buffer or wants more than 60 % of the file (the bound the
|
||
import path's own partial read uses), or a window that does not decode all answer `None`, and the
|
||
loader reads the file whole exactly as before: the plan can cost bytes but never the picture.
|
||
|
||
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 Adobe 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 lightcraft-raw --release --test
|
||
sparse_corpus -- --nocapture` prints these numbers. `LIGHTCRAFT_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](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).
|
||
|
||
- `Session::embedded_thumb_is_final(photo)` says whether the photo is an unedited raw whose camera
|
||
JPEG the viewer is showing (the same "unedited raw" gate as the preview path: `has_import_look`).
|
||
- `grid::skips_the_real_render(embedded_is_final, from_embedded)` is the decision, unit-tested:
|
||
a **cached render** as the stand-in, an **edited photo** and a **raw with no embedded JPEG** all
|
||
still render, because their thumbnail must show the pipeline's output rather than the camera's.
|
||
- Whether the embedded JPEG can actually be read is only known when the quick job runs, so
|
||
`Session::quick_thumb_job` carries the real thumbnail job as its last resort: a raw whose preview
|
||
the loader can't read renders the raw instead of leaving the cell empty (issue #1448).
|
||
- The thumbnail key still changes with the settings hash, so editing a photo re-renders its
|
||
thumbnail through the ordinary background path.
|
||
|
||
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 (`lightcraft_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
|
||
|
||
- `crates/raw/src/preview.rs`: the header-only range matches `embedded_preview` byte for byte; a
|
||
clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all
|
||
covered. `a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape` covers the picker: a
|
||
smaller JPEG that covers the box is taken, the largest when none does, and a thumbnail of another
|
||
shape or a grey mask never is.
|
||
- `crates/engine/src/files.rs`: on a synthetic RAF with a 200 KB tail, the quick path and the
|
||
whole-file path produce identical pixels, and the read stays under half the file. On a synthetic
|
||
TIFF/DNG whose preview only its tags locate — the plan path — the same identity holds and the CFA
|
||
strip is not read; the same structure without the tag that makes it a raw declines instead of
|
||
guessing, and the caller keeps reading it whole. So does a file whose own orientation only the
|
||
whole-file path can show. One that states Adobe RGB takes the plan route and shows the same pixels
|
||
as the whole-file path (the space changes them: decoded as sRGB they differ), and one that states
|
||
sRGB stays on the fast path. A synthetic DNG with a full-size and a half-size JPEG gets, on the plan
|
||
path, the half-size one for a box it covers and the full-size one otherwise, with the same pixels as
|
||
the whole-file path (`preview_loading_plans_the_smallest_covering_jpeg`).
|
||
- `crates/raw/tests/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only
|
||
the ones whose header plan is `Ready`, and the preview it yields must equal the whole file's with
|
||
the skipped bytes `0` and `0xFF`. The test prints what each format reads.
|
||
- `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real**
|
||
file, typically on a share:
|
||
|
||
```sh
|
||
LC_PREVIEW_RAW="/mnt/nas/…/DSCF0001.RAF" \
|
||
cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture
|
||
```
|
||
|
||
No raw fixture is committed (media never is, see `AGENTS.md`).
|
||
|
||
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.
|