Skip to main content

Color Correction & Scopes

Color correction is a per-pixel filter of the transform shader, FilterKind::ColorCorrection. The scopes that check it are compute passes in vv_render::Scopes. Both live next to the compositor and share its device.

Model

ClipFilter::grade is a GradeTracks: one Keyframed<f32> per GradeParam, like the transform, so every control animates on its own and the generic keyframe code covers them all.

ParamsMeaning
Shadows, Midtones, Highlights: X, Y, Luma, SatOne wheel per luma range: color shift, luminance shift, saturation
Offset: X, Y, LumaThe same shift on the whole image, whatever its luma
SaturationGlobal, shown with the offset wheel
LowRange, HighRangeLuma where shadows give way to midtones, and midtones to highlights

The tracks are saved by param name, so GradeParam can change order; a param missing from a file loads neutral. The old Grayscale filter loads as a color correction with the black and white preset.

The math

  • A puck at (x, y) in the unit disc is a Cb/Cr shift, as on a vectorscope; its strength grows with the square of the distance from the center, for fine control near it. chroma_shift turns it into RGB with no change in luma.
  • Each pixel gets soft shadows/midtones/highlights weights from its Rec.709 luma (range_weights, smoothstep around the two range edges, summing to 1).
  • The pixel adds each wheel's shift times its weight, plus the offset; its saturation is scaled by the weighted range saturations times the global one, around its luma; the result is clamped.

range_weights and apply_grade exist twice: in transform.wgsl and in vv_core::grade on the CPU. A compositor test renders a set of grades and colors, range edges included, in both precisions and compares them with the CPU reference.

Presets and keyframes

  • GradeTracks::apply_preset: Neutral resets every param; black and white sets only the params it moves (the saturation), so on a graded clip it only desaturates. The same function serves the preset buttons and the filter dropped from the Effects panel.
  • A wheel's four params share one keyframe row in the UI: once one of them is animated, an edit of another sets a keyframe too, instead of changing its value over the whole clip.

Auto balance

vv_core::auto_balance moves the three range wheels so that the surfaces that should be neutral turn grey:

  1. Pixels near black or white are dropped; the others get their Cb/Cr and range weights.
  2. Each range's cast starts as the median Cb/Cr of its pixels, then becomes the mean of the pixels close to it, twice. A large colored object is left out instead of pulling the picture towards its opposite.
  3. The ranges overlap, so a wheel also moves its neighbours: the three moves are solved together, Cb and Cr in one elimination. A range without enough neutral pixels, or desaturated, keeps its wheel; a move out of reach stops at the disc's edge.

It measures the selected clip by itself: the app composes only its track (plus the tracks below for an adjustment clip, which works on them) at the playhead, reads it back and balances. The button needs a single clip under the playhead.

Scopes

Scopes::render runs three compute passes on one bin buffer, cleared per render:

  1. accumulate: atomic counts per bin, on the source sampled down to about 1024 pixels per axis.
  2. histogram_max: the tallest bin, for the histogram only.
  3. draw: the bins into an Rgba8Unorm storage texture, one per display slot, redrawn in place while its size holds.
ScopeBins
Waveform256 luma levels per column, up to 512 columns
RGB paradeThe same per channel, three panels
Vectorscope256 × 256 Cb/Cr
Histogram256 levels for R, G, B and luma

The graticule (scale lines, 75% bar targets, skin tone line) is painted by egui over the texture.

Color window

The window (color_window.rs) shows two scope slots above the grade section of the first selected clip. What the scopes measure is not the viewer's frame but the clip composed by itself at the playhead, the same isolation as the auto balance, into an owned work texture (a pooled output would be drawn over by the viewer's next frame). It is recomposed only when its layer stack renders differently, and a scope slot is redrawn only when its frame, scope or size changes.