You can't grade color on the pixels you see

How we bring color-grading to social video in the browser

Why good color grading never touches the pixels you see — and how we run a cinema-grade pipeline live in a browser tab, then reproduce it exactly on export.

Christoph Schütte
Christoph Schütte

Drag an exposure slider one stop to the right. You expect twice the light. In most quick editors you get something subtly wrong instead — the bright colors skew, skin goes plasticky, a saturated red drifts toward orange. Nothing crashed. The math just ran on the wrong numbers.

Here's the uncomfortable bit: the pixels you see on screen are not the amount of light in the scene. They're an 8-bit sRGB encoding of it — a curve deliberately bent so that dark tones get more of the 256 code values, because that's where your eye is fussy. It's a storage format, not a measurement. And color math — exposure, contrast, white balance — is physics. It only comes out right when you do it on light, not on the bytes that stand in for light.

So the first thing our color grader does with every pixel is throw away the encoding.

The slider is doing arithmetic on the wrong numbers

One stop of exposure means "twice as much light." Twice as much light is × 2 — but only in linear space, where the numbers are proportional to photons. Do it on the sRGB byte and you're multiplying a value that's already been through a ^(1/2.4)-ish curve, so the increase isn't one stop, it isn't uniform across the range, and — because the three channels sit at different points on that curve — it isn't even hue-preserving. The naive version:

glsl
// Exposure & contrast "on the pixels you see" — wrong.
rgb *= exp2(stops);          // sRGB-encoded bytes
rgb  = (rgb - 0.5) * c + 0.5;

The correct version decodes first, does the physics in linear light, and only re-encodes at the very end:

glsl
vec3 v = EOTF_sRGB(color.rgb);   // 0. sRGB byte  → linear light
v *= exp2(uExposure);            // 1. exposure, where ×2 really is one stop
// … every other stage, still in linear …
color.rgb = OETF_sRGB(v);        // 7. linear light → sRGB byte, last thing we do

EOTF_sRGB is the exact piecewise sRGB curve, not a pow(x, 2.2) approximation — the linear toe near black matters, because that's exactly where sRGB spends its precision.

Remember: any color operation on gamma-encoded bytes is quietly wrong. Decode to linear light first, or your exposure slider is also a hue slider.
Left: +1 stop of exposure applied straight to the sRGB bytes. Right: the same stop applied in linear light. Same photo, same slider — the only difference is whether the math ran on light or on the encoding. The insets on the saturated blanket and the skin tones show the naive version skewing hue and going plasticky while the linear version holds.
Left: +1 stop of exposure applied straight to the sRGB bytes. Right: the same stop applied in linear light. Same photo, same slider — the only difference is whether the math ran on light or on the encoding. The insets on the saturated blanket and the skin tones show the naive version skewing hue and going plasticky while the linear version holds.

We didn't invent this. We transcribed it.

Getting color right is a genuinely deep rabbit hole — chromatic adaptation, working gamuts, tone mapping, log encodings — and the honest move when you find a world-class reference is to copy it, not to improvise your own. Google's Filament is a real-time physically-based renderer with a color grading pipeline that professional graphics engineers have already argued over. So our shader is a faithful port of Filament's ColorGrading.cpp — the same stage order, the same working space, the same matrices and constants, transcribed from its LUT builder and cited line by line in the source. And we don't take the transcription on faith: every matrix we port is checked against its published reference value in the test suite.

The pipeline it defines, and that we run per pixel:

The eight-stage color pipeline, with the working color space labeled at every hop: sRGB decode into linear light, exposure, into the Rec.2020 working space, CIECAT16 white balance, tonal ranges, contrast in LogC, saturation, then Rec.2020 back to sRGB and the sRGB encode.
The eight-stage color pipeline, with the working color space labeled at every hop: sRGB decode into linear light, exposure, into the Rec.2020 working space, CIECAT16 white balance, tonal ranges, contrast in LogC, saturation, then Rec.2020 back to sRGB and the sRGB encode.

That's the abstract version. Here's the same pipeline actually running — an ordinary 8-bit photo walked through the stages one at a time (the panels are numbered to match the diagram above):

Six panels of a beach scene run through the pipeline, numbered to match the diagram: the untouched 8-bit original; stage 0 decode from sRGB to linear light; stage 1 exposure; stage 3 white balance; stage 5 contrast; stage 6 saturation landing the final graded frame.
Six panels of a beach scene run through the pipeline, numbered to match the diagram: the untouched 8-bit original; stage 0 decode from sRGB to linear light; stage 1 exposure; stage 3 white balance; stage 5 contrast; stage 6 saturation landing the final graded frame.

Every panel there is the real pipeline's output — a faithful CPU port of the shader, checked so a neutral grade is the identity to within 0.002 of a code value. The two stages where "correct" diverges most sharply from "what a slider usually does" are the ones we'll dwell on next: contrast, and white balance.

Contrast belongs in log space, pivoted on middle gray

The obvious way to add contrast is to push values away from the midpoint in linear light. It works, technically, and it looks harsh — highlights race to white and clip, because linear light has an enormous range up top and a naive gain sends the bright end flying.

Colorists don't do that. DaVinci Resolve applies contrast in a log encoding, and so do we. We encode linear light into an ALEXA LogC curve first — a curve built so that equal ratios of light become roughly equal steps, the way film density behaves — then apply the gain there, then decode back:

glsl
vec3 contrast(vec3 v, float c) {
    float offset = MIDDLE_GRAY_ACEScct * (1.0 - c);  // keep 18% gray fixed
    return v * c + offset;
}

That offset is the whole trick. Contrast pivots around a point, and the right point isn't 0.5 — it's 18% gray, the photographic middle, which in this log encoding sits at 0.4135884. Pivoting there means turning contrast up darkens the shadows and lifts the highlights symmetrically around the tone your eye reads as "middle," instead of around an arbitrary numeric half. Same slider, film-like result, no blown highlights.

Remember: contrast in a log space pivoted on 18% gray keeps middle gray put and the roll-off gentle. Contrast in linear light just blows the top end out.

White balance isn't a warm filter — it's modeling your eye

"Make it warmer" in a naive editor adds orange. That's not how the eye works, and it's not how a camera's white balance works either. Your visual system adapts to the color of the light: a white shirt looks white under warm tungsten and under blue daylight because your cone responses rescale to the illuminant. Correcting white balance means undoing that — computationally re-adapting the image as if it were lit by a different white.

The rigorous way to do that is a von Kries chromatic adaptation in cone space. We convert the pixel from Rec.2020 into CIECAT16 LMS — a model of the three cone responses — scale each cone by the ratio of the target white to the source white, and convert back:

glsl
// White balance — CIECAT16 von Kries adaptation.
v = LMS_CAT16_TO_REC2020 * (uWhiteBalance * (REC2020_TO_LMS_CAT16 * v));

The two sliders don't map to "orange" and "green." Temperature slides the target white along the daylight locus — the arc of colors real daylight actually takes, from tungsten to overcast sky — and tint moves it perpendicular to that arc. So "warmer" walks the white point through physically plausible illuminants, which is why it stays believable at the extremes where an added orange tint would look like a filter.

One honest little detail from that code, because it stung. At neutral — temperature and tint both zero — the D65-to-D65 round-trip through CIECAT16 doesn't land exactly on [1, 1, 1]; it's off by about 1e-4. Left alone, "no white balance" would apply a sub-perceptible tint and, worse, disagree with our server renderer, which gates the stage off entirely at neutral. So there's a one-line guard that returns exactly [1, 1, 1] when both sliders are zero. Neutral has to mean neutral, bit for bit.

One reference, two engines

The reason to be this disciplined shows up at the very end of the product.

The grade you nudge in the browser runs in a WebGL fragment shader — 60fps, on the GPU, on the frame PixiJS already has decoded. But when you hit export, the final render happens headless on a server, in a SIMD C++ kernel. Two completely different implementations: one in GLSL, one in hand-vectorized C++ over Google Highway.

They cannot be allowed to disagree. If the preview and the render used even slightly different color math, every export would be a small betrayal of what you signed off on. So both are ports of the same Filament reference, down to the shared constants — the same SRGB_TO_REC2020 matrix, the same MIDDLE_GRAY_ACEScct = 0.4135884 pivot, the same LogC coefficients — sitting in a .frag file and a .cc file that read almost identically:

cpp
// cpp/fast_math/color_grader.cc — the headless renderer
constexpr float MIDDLE_GRAY_ACEScct = 0.4135884f;
constexpr float LUM[3] = {0.2627002f, 0.6779981f, 0.0593017f};  // Rec.2020
// The SIMD kernel only has Exp2/Log2, so rebase the curve's log10/pow10 …

That last comment is the tell that this is real and not decorative: the GPU shader and the CPU kernel both lack a native log10, so both rebase the LogC curve through base 2 the same way. The same accommodation, made twice, so a creator's browser preview is the render.

The grade was never the sliders

The sliders are the least interesting part of a color grader. Exposure, contrast, saturation — you can wire those to rgb *= in an afternoon and ship something that looks like it works, until someone drags a slider to an extreme, or exports and notices the render doesn't match the preview. What actually makes it work is the boring, invisible substrate: decode to linear light, pick a working space, do contrast where film does it, and model the eye for white balance.

If there's one thing to steal from this: do your color math in linear light, and treat the sRGB bytes as nothing more than the envelope they arrived in. Decode on the way in, encode on the way out, and let every operation in between run on light. The cinema tools have done it this way for decades. The only new part is that it now fits in a shader in a browser tab.


Solid is an AI video editor. If porting cinema color science into a shader sounds like your idea of fun, we're hiring.