LutStyles: Real-Time LUT Previews in the Browser with WebGL
- WebGL
- Three.js
- React Three Fiber
- color grading
- Next.js
- LutStyles
If you shoot flat log profiles — DJI D-Log M, Sony HLG3 — you know the workflow tax: footage looks washed-out gray until graded, and checking whether a LUT suits a clip means importing it into an NLE, dropping the LUT on, and waiting for conform. For a ten-second "does this look work?" question, that's absurd.
LutStyles is my answer: drag a video and a .cube LUT into a browser tab and see the grade applied in real time. No upload, no account, no install. The entire pipeline runs on your local GPU — footage and LUTs never leave the machine, which doubles as both a privacy guarantee and a $0 compute bill.
The pipeline
The architecture is a straight line:
Local file → URL.createObjectURL → <video> + LUTCubeLoader
→ THREE.VideoTexture → EffectComposer (LUT pass) → canvas
Stack: Next.js 16, React 19, Three.js with @react-three/fiber, and @react-three/postprocessing for the LUT shader pass. The viewer is dynamically imported with ssr: false — there's nothing to server-render about a WebGL context.
The most important decision is what the JavaScript doesn't do: decode video. The app plays the clip in a standard hidden <video> element — letting the browser's hardware decoder do its job — and wraps it in a THREE.VideoTexture. Decoded frames travel to the GPU as textures without JS ever touching pixel data. The texture is tagged THREE.SRGBColorSpace, which is the difference between a faithful preview and a mysterious gamma shift that makes every LUT look wrong.
The LUT itself is parsed by Three's LUTCubeLoader into a 3D lookup texture, applied as a single post-processing pass. One shader, one pass, 60fps on integrated graphics.
The unglamorous bugs
Stale LUT races. .cube files for high-resolution LUTs aren't tiny, and parsing is async. Swap the video while a LUT is still parsing and a naive implementation applies the old LUT to the new state. The fix is the classic effect-cancellation pattern, plus storing which source the parsed result belongs to:
const [loaded, setLoaded] = useState<{ src: string; result: LUTCubeResult } | null>(null);
useEffect(() => {
if (!lutSrc) return;
let active = true;
new LUTCubeLoader().load(lutSrc, (result) => {
if (active) setLoaded({ src: lutSrc, result });
});
return () => { active = false; };
}, [lutSrc]);
const lut = loaded && loaded.src === lutSrc ? loaded.result : null;
The guard on the last line matters as much as the flag: even a result that arrives "successfully" is discarded if the selection moved on.
Blob URL leaks. URL.createObjectURL pins the underlying file in memory until explicitly revoked. Drop five 4K clips in without revoking and the tab balloons by gigabytes. Every source swap and unmount revokes the previous URL in an effect cleanup.
Aspect ratio without camera math. The video renders on a plane mesh that must letterbox/pillarbox like a real monitor. Instead of fiddling with projection matrices, the plane reads R3F's viewport and compares aspect ratios — wider than the viewport, constrain by width; taller, constrain by height. Four lines, resize-proof.
Cheap film-look, zero GPU cost
The reference-monitor framing — film grain and vignette — costs nothing on the WebGL side because it isn't WebGL. Grain is a CSS ::before pseudo-element carrying an inline-SVG fractal noise data URI with mix-blend-mode: soft-light; the vignette is a radial gradient on ::after. Both are pointer-events: none so the canvas stays interactive. The compositor blends them for free while the GPU budget stays reserved for the actual color math.
What's next
Phase 1 — the local engine — is done and live at lutstyles.com. Phase 2 adds a split-screen before/after slider, LUT intensity control, and frame extraction for testing grades on stills. The principle stays fixed: your footage never leaves your machine.