How a photo gets on screen
To open one photo, the app does one, two or three decodes, and the file format does not decide how many. This post follows the whole trip from a file on disk to pixels on your monitor.
The 1616 px preview is smaller than 2560 px. You see it after 35 ms, then the 250 ms decode swaps in.
Three different jobs
A photo takes one of three paths through the app. I treat each path as its own problem.
- Opening. The time from your click to the first image on screen is what counts.
- Browsing. The grid needs a small version of every visible photo, fast.
- Exporting. The JPEG must be exactly right. It can take as long as it needs.
Opening a photo
A camera RAW file is not really an image yet. It is the raw readout from the sensor, and to turn it into a picture takes real work, called demosaicing. On my machine, that takes approximately 250 ms for a Sony ARW file at screen size.
But almost every RAW file also has a small JPEG preview that the camera saved inside it. On my Sony, the preview is 1616 pixels on the long side, and it takes approximately 35 ms to extract. So the first pass gets the cheapest decode it can near the size I asked for, using the embedded preview if there is one worth using. Then the app checks one thing.
Is the result as big as the window actually needs?
If it is, the app stops after one decode. That happens with every JPEG, because a JPEG has no embedded preview, so the first pass decodes it at the requested size. It also happens with a RAW in a small window. If the loupe (the single-photo view) needs only 1400 pixels, the 1616-pixel camera preview is already bigger than that, so the expensive decode never runs.
If the result is too small, the app queues a second decode at the full target size and swaps it in when that decode is done. That is my usual case, because my window asks for approximately 2560 pixels and the camera preview is 1616. Showing the preview first turned a 250 ms wait into a 35 ms wait, but nothing actually got faster. The app shows something useful while the expensive work runs, and it skips that work when it is not needed.
My first rule checked whether the file was a RAW. That would have grown into a list of special cases for each format. A check of the result size is one rule that covers every format, including formats I have not added yet.
A full-resolution decode takes seconds and hundreds of megabytes of memory, so the third pass runs only when you zoom in past the point where the screen-size version still has enough detail.
When you use the arrow keys to move through a shoot, the app loads the photos on each side ahead of time, but only with the cheap first pass. A full decode for a photo that you have not looked at yet would compete with the photo that you are looking at now. When you move onto that photo, it gets the full decode like any other.
After that, it all happens on the graphics card
The app sends a decoded photo to the graphics card (the GPU) once. Zooming and panning never touch the image data again. They only change a few numbers that the app sends to the card each frame. Anything that happens 60 times a second must never redo the decode.
The sliders work the same way. When you drag Exposure, only the new value goes to the GPU. A shader, a small program on the card, redraws the picture on the fly. Nothing is decoded again, and the main processor never loops over the pixels.
Two ways of drawing
egui, a Rust interface library that redraws the whole interface every frame, draws the buttons, grid, filmstrip and sliders. A few hundred lines of hand-written GPU code draw the photo underneath a transparent panel.
This split lets me write the interface in the loose, convenient style that egui encourages, and keeps the pixel-heavy part small enough to tune by hand. In a general-purpose toolkit, the photo would be a widget, and it is hard to make a widget take a 24-megapixel image with a custom shader.
Where the four builds meet
The macOS, Linux, Windows and browser builds differ in only one place: how they turn a file into pixels.
The GPU tone shader is a direct translation of that one edit function. The function that bakes your edits into finished pixels has exactly two users: the exporter and the thumbnail you are looking at.
That is why an exported JPEG matches what you saw on screen. It is not a separate feature. It comes from having only one code path, and I prefer that to keeping two paths the same by hand.
The downloads page has builds for all four platforms.