Problem
I have a set of Lightroom presets, including Kodak film emulations I made myself. They only work inside Lightroom. I wanted anyone to be able to try them on their own photo, in the browser, with nothing uploaded.
Constraints
- GitHub Pages hosting: no server, so every pixel is processed on the visitor's device.
- Lightroom presets are not LUTs. Only global colour and tone can be baked out; grain, vignette and other spatial effects cannot.
- The editor route has a budget of 120 KB gzipped of its own code.
What I built
The pipeline. Each look starts as a HALD image: a 512 x 512 picture holding a 64 x 64 x 64 cube of colours. I grade that image with the preset in Lightroom and export it as a 16-bit TIFF. A script reads it with sharp and writes two files: a .cube file that works in Resolve, Premiere and Photoshop, and an 8-bit PNG of the same cube, which is what the browser loads because it is far smaller than the .cube (about 0.4 MB against 6.9 MB for the first demo look). Pixel i of the image maps to the colour r = i mod 64, g = (i / 64) mod 64, b = i / 4096. That is red-fastest, the same order as a .cube file and as a WebGL 3D texture upload, so no data is ever reshuffled.
The editor. It is raw WebGL2: one triangle and one fragment shader, no three.js. The photo is a 2D texture and the look is a 64-cube 3D texture with linear filtering, so the GPU interpolates between the cube's grid points. The shader looks up the graded colour, blends it with the original by the strength slider, then adds film grain and a vignette. The preview renders a copy with its long edge at 1600 px. Export decodes the original, renders it at full size (capped by the GPU's texture limit and 6000 px, and it says so if it was capped) and saves a JPEG with no metadata.
What a LUT cannot do. It only remaps colour. Grain, vignette, clarity and sharpening depend on neighbouring pixels, so they are left out of the bake and recreated in the shader. The grain is hash noise, strongest in the midtones where real film grain shows most.
Decisions and trade-offs
Raw WebGL2 instead of three.js. The job is one quad and one shader. Going without three.js keeps the whole route to about 6 KB gzipped of its own code, against a 120 KB budget. The cost is writing the texture setup by hand.
Start the GPU on intent, not on load. The page first shows the sample as a plain image. WebGL starts when the visitor hovers, touches or focuses the editor. Compiling shaders and decoding images before anyone used the tool left the page at 49 on mobile Lighthouse. Starting on first use brought it to about 90, and nobody who just wanted to read paid for it.
Bake the LUT from the preset instead of reimplementing it. Rebuilding Lightroom's tone curve and HSL maths in a shader would never match exactly. Grading a known image through Lightroom captures whatever it really does. The price is that the look is fixed: you cannot drag Lightroom's own sliders.
Everything stays on the device. There is no upload, no server and no function behind this page, so the privacy line at the bottom of the editor is true by construction.
Results
Measured on my development machine with a generated 6000 x 4000 test JPEG, not a real photo:
- Decode and grade a 24 MP image for preview: about 0.4 to 0.6 seconds, of which the grade itself is 25 to 65 ms.
- Full-size export of the same image: about 1 second, 3.3 MB, no metadata.
- The identity LUT leaves every pixel within 1/255 of the original. The GPU result matches a plain CPU trilinear lookup to within 1/255 as well.
- The editor's own code is about 6 KB gzipped. Mobile Lighthouse for the page: 90 to 94 performance, 100 accessibility.
What I would do differently
- Check the colour handling on more devices. I told the browser not to colour-manage the LUT image, but wide-gamut displays and a few phones convert images in ways I could not test here. A calibrated side-by-side on real screens would settle it.
- Read the real metadata. A phone photo that carries an Adobe RGB or Display P3 profile is treated as sRGB. Honouring the profile is the next correctness fix.
- Offer a fixed-size export. Today export is full size or capped by the device. A "for Instagram" size would be the most-used option.