LightPhotos

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 CHECK AFTER PASS 1 Pass 1 result: 1616 px camera preview Window needs 2560 px DECODES THAT RUN Pass 1 · 35 ms Pass 2 · 250 ms Pass 3 · full size, seconds 2 decodes Then: one upload to the GPU. A slider, zoom or pan sends only a few numbers.

The 1616 px preview is smaller than 2560 px. You see it after 35 ms, then the 250 ms decode swaps in.

The rule is the size of the result, not the file format. Make the window smaller than 1616 px and the RAW needs only one decode.

Three different jobs

A photo takes one of three paths through the app. I treat each path as its own problem.

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.

macOS Linux Windows Browser ImageIO Rust decoders same, in Web Workers THE ONLY FORK. EVERYTHING BELOW IS SHARED One edit function, in linear light Tone shader on the GPU Exported JPEG
The platforms split only at the decode step. Everything after that is shared, so the platforms cannot drift apart.

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.