Ffmpeg Lut Grade
Context: $ARGUMENTS
Quick start
- Apply a .cube LUT: → Step 1 (LUT file path) → Step 2 (
lut3dwithinterp=tetrahedral) - Apply a Hald CLUT PNG: → Step 1 (pick
haldclut) → Step 2 (-filter_complex [0][1]haldclut) - Grade without a LUT (balance/curves): → Step 1 (pick filter-based) → Step 3 (stack correctly)
- Match two clips: → Step 3 (per-clip LUT + manual colorbalance trim)
- Fix legacy BT.601 → BT.709: → Step 1 (
colormatrix)
When to use
- You have a
.cube,.3dl, or Hald CLUT PNG and need to bake the look into a render. - You need non-destructive primary/secondary color correction (balance, selective color, levels).
- You need to normalize legacy broadcast footage (BT.601 ↔ BT.709 matrix).
- You want to match shot-to-shot color after editing.
Do not use this skill for HDR → SDR tone-mapping (see ffmpeg-hdr-color), denoise
(ffmpeg-denoise-restore), or generic video filters like scale/crop
(ffmpeg-video-filter).
Step 1 — Pick the grading tool
Decide by asset, not vibe:
| You have | Use |
|---|---|
.cube or .3dl file |
lut3d=file='look.cube':interp=tetrahedral |
1D LUT (.cube with LUT_1D_SIZE) |
lut1d=file='look.cube' |
| Hald CLUT PNG (graded identity image) | [0][1]haldclut=interp=tetrahedral (filter_complex) |
| Need R/G/B tint per zone | colorbalance (shadows/mids/highlights) |
| Want only reds/skin/sky shifted | selectivecolor |
| Legacy BT.601 tagged as BT.709 (or v.v.) | colormatrix=bt601:bt709 (legacy, non-ICtCp) |
| Crush shadows / recover highlights | colorlevels |
| Channel-mix R/G/B | colorchannelmixer |
| Hue rotate / desaturate | hue=h=15:s=0.9 |
| Brightness/contrast/gamma/saturation | eq (see ffmpeg-video-filter for full table) |
| False-color exposure analysis | pseudocolor=preset=heat |
Validate the LUT file exists and has the expected header (see references/filters.md
".cube spec"). If the LUT is a Hald identity that hasn't been graded yet, applying it
is a no-op — confirm it's the graded version.
Step 2 — Apply it and verify
Apply .cube (most common)
ffmpeg -i in.mp4 -vf "lut3d=file='look.cube':interp=tetrahedral" \
-c:v libx264 -crf 18 -c:a copy out.mp4
Apply Hald CLUT
ffmpeg -i in.mp4 -i clut.png \
-filter_complex "[0:v][1:v]haldclut=interp=tetrahedral" \
-c:v libx264 -crf 18 -c:a copy out.mp4
Filter-based grade (no LUT file)
# Warm shadows, cool highlights
ffmpeg -i in.mp4 -vf "colorbalance=rs=.15:gs=.05:bs=-.1:rh=-.1:gh=.0:bh=.1" \
-c:v libx264 -crf 18 out.mp4
# Reduce red saturation (skin safety or red-dominant scenes)
ffmpeg -i in.mp4 -vf "selectivecolor=reds=0 0 -0.5 0" \
-c:v libx264 -crf 18 out.mp4
# Shadow crush + highlight roll-off
ffmpeg -i in.mp4 -vf "colorlevels=rimin=0.05:gimin=0.05:bimin=0.05:romax=0.95:gomax=0.95:bomax=0.95" \
-c:v libx264 -crf 18 out.mp4
Legacy matrix conversion
# SD footage tagged 709 that actually carries 601 primaries
ffmpeg -i sd.mxf -vf "colormatrix=bt601:bt709" -c:v libx264 -crf 18 out.mp4
Verify with vectorscope / waveform (ffplay)
ffplay -i out.mp4 -vf "split=2[a][b];[b]vectorscope=mode=color3[c];[a][c]hstack"
ffplay -i out.mp4 -vf "waveform=intensity=0.1:mode=column:display=overlay:components=7"
Skin tones should land on the flesh-line at ~10-11 o'clock on the vectorscope; clipped channels park on the outer ring.
Step 3 — Stack grading ops correctly
Grading is non-commutative. Recommended chain order (coarsest → finest):
format=rgb24(orzscaleto linear) — ensure LUT sees RGB if the LUT is RGB-domain.eq(exposure / contrast baseline) — optional; many colorists skip and use levels.colorbalanceorcolorchannelmixer— primary white balance / cast fix.lut3d/haldclut— the creative look.selectivecolor— secondary corrections (skin, sky) on top of the look.colorlevels— final output range shaping.- Output:
format=yuv420pfor H.264/H.265 compatibility.
Full chain example (teal-and-orange):
ffmpeg -i in.mov -vf \
"format=rgb24,\
colorbalance=rs=.05:bs=-.05:rh=-.05:bh=.08,\
lut3d=file='teal_orange.cube':interp=tetrahedral,\
selectivecolor=reds=0 0 0.2 0:yellows=-0.1 0 0.1 0,\
colorlevels=rimin=0.02:gimin=0.02:bimin=0.02,\
format=yuv420p" \
-c:v libx264 -crf 18 -c:a copy out.mp4
Match two clips A and B (different cameras, same scene):
# Clip A: apply camera-A LUT + subtle cool
ffmpeg -i A.mov -vf "lut3d=file='camA_to_rec709.cube',colorbalance=bh=.03" -c:v prores -profile:v 3 A_match.mov
# Clip B: apply camera-B LUT + subtle warm
ffmpeg -i B.mov -vf "lut3d=file='camB_to_rec709.cube',colorbalance=rh=.03" -c:v prores -profile:v 3 B_match.mov
Available scripts
scripts/grade.py— thin wrapper around the filters above. Subcommands:lut,haldclut,balance,selective,match-bt601-to-bt709. Validates.cubepath, supports--dry-runand--verbose, stdlib only.
Usage examples:
uv run ${CLAUDE_SKILL_DIR}/scripts/grade.py lut \
--input in.mp4 --lut look.cube --output out.mp4 --interp tetrahedral
uv run ${CLAUDE_SKILL_DIR}/scripts/grade.py haldclut \
--input in.mp4 --clut clut.png --output out.mp4
uv run ${CLAUDE_SKILL_DIR}/scripts/grade.py balance \
--input in.mp4 --output out.mp4 \
--shadows 0.15,0.05,-0.1 --mids 0,0,0 --highlights -0.1,0,0.1
uv run ${CLAUDE_SKILL_DIR}/scripts/grade.py selective \
--input in.mp4 --output out.mp4 --color reds --adjust 0,0,-0.5,0
uv run ${CLAUDE_SKILL_DIR}/scripts/grade.py match-bt601-to-bt709 \
--input in.mxf --output out.mp4
Reference docs
- Read
references/filters.mdfor full option tables (lut3d, lut1d, haldclut, colorbalance, selectivecolor, colormatrix, colorlevels, colorchannelmixer, hue, pseudocolor),.cubespec, Hald identity generator, the recommended chain order, and a recipe book (teal-and-orange, bleach bypass, cross-process, sepia, B&W with tinted shadows, skin-tone protection).
Gotchas
interp=tetrahedralbeatstrilinearfor nearly all .cube LUTs — smoother gradients, no visible banding at hue transitions. Prefer it unless you need bit-exact match with another NLE that used trilinear..cubesupports LUT_3D_SIZE 17, 33, 64, 128 in ffmpeg. Comments start with#.DOMAIN_MIN/DOMAIN_MAXoutside0.0 1.0means the LUT expects extended range (log footage) — converting to Rec.709 first will clip it.- Hald CLUT requires a graded identity PNG, not the raw identity. Someone runs the identity through Photoshop/Affinity/DaVinci, saves, and hands you the result. Applying the raw identity is a no-op.
- Order matters.
eq→lut3d→colorbalancebakes contrast into the LUT input;lut3d→colorbalancelets you push tint without affecting the LUT shape. Document intent; don't swap silently. - LUTs baked in Rec.709 assume Rec.709 input. Feeding sRGB, log, or HDR to a
Rec.709 LUT produces wrong colors. Convert first with
colorspace/zscale, or use a matching log-to-Rec709 LUT (e.g. Slog3, LogC, VLog). selectivecolorvalues are space-separated 4-tuplesC M Y K, not commas.selectivecolor=reds=0 0 -0.5 0is correct;reds=0,0,-0.5,0is an error.colorbalancerange is roughly -1..1 but anything over0.2on any axis is usually too strong and crushes the channel. Start at0.05.- GPU LUT?
lut3druns on CPU. For hardware-accelerated grading, uselibplacebowithcustom_lut='look.cube'(Vulkan) or VAAPI'slut3dvariant on Linux Intel. - Hald CLUT level 8 = 512×512 is the common size. Level 6 = 216×216 is faster but loses precision. Always verify with an identity round-trip.
- Two LUTs back-to-back compound interpolation error. Bake one accurate LUT in a grading app instead of stacking.
- Most
.cubeLUTs are RGB-domain. If input is YUV, insertformat=rgb24beforelut3d— otherwise ffmpeg auto-converts but may pick a narrower pixel format. - Verify with
vectorscope. Skin tones should sit on the flesh-line at ~10-11 o'clock. Clipped channels appear as bright spots on the outer ring. - Never apply a LUT after HDR→SDR tone-mapping unless the LUT was specifically designed for the mapped output. Tone-map → 709 → LUT is almost always wrong; LUT-at-log → tone-map is correct for log sources.
Examples
Example 1: Apply a film emulation .cube to a MOV
Input: raw.mov (ProRes, Rec.709, 1080p), LUT kodak2383.cube (17^3).
Steps: lut3d=file='kodak2383.cube':interp=tetrahedral → x264 CRF 18.
Result: graded.mp4 with film print look baked in; source untouched.
Example 2: Match camera-A to camera-B on a two-camera interview
Input: A.mov (FX3, Slog3), B.mov (A7S3, Rec709).
Steps: Apply slog3_to_rec709.cube to A; apply mild colorbalance=bh=.02 to B.
Result: Both clips on the same Rec.709 baseline; fine-tune with selectivecolor
on skin if needed.
Example 3: Hald CLUT from a Photoshop action
Input: trip.mp4, hald_level8_graded.png (someone ran an LR preset over the
identity and exported).
Steps: [0:v][1:v]haldclut=interp=tetrahedral in -filter_complex.
Result: Photoshop-preset look on video, deterministic and repeatable.
Troubleshooting
Error: Unable to parse option value "..." for 'file'
Cause: Path has a space or special char and isn't single-quoted inside the filter.
Solution: Quote the path inside the filter graph: lut3d=file='My LUTs/look.cube'.
Output looks washed out / low-contrast
Cause: LUT expects log input but you fed it Rec.709; LUT is designed to expand log to 709, so a Rec.709-in → Rec.709 LUT produces flat results. Solution: Confirm the LUT's intended input space. Use a matching LUT or skip it.
Output has visible banding in gradients
Cause: interp=nearest or trilinear, or 8-bit pipeline with an aggressive LUT.
Solution: interp=tetrahedral. For stronger grades, encode output at 10-bit
(-pix_fmt yuv420p10le -c:v libx265 or ProRes).
haldclut fails with "Invalid frame dimensions"
Cause: CLUT PNG isn't a valid Hald size (level^3 × level^3 pixels, so level 8 =
512×512, level 6 = 216×216).
Solution: Regenerate the identity via
ffmpeg -f lavfi -i haldclutsrc=level=8 -frames:v 1 hald_identity.png, regrade it,
and retry.
selectivecolor throws "Option not found" / zero effect
Cause: Commas used instead of spaces inside the 4-tuple.
Solution: selectivecolor=reds=0 0 -0.5 0 (spaces, not commas). Quote the whole
filter arg in shell: -vf "selectivecolor=reds=0 0 -0.5 0".
Colors look right on your monitor but wrong on web / phones
Cause: Output tagged with non-standard primaries/transfer, or pipeline stripped tags.
Solution: End the chain with format=yuv420p and re-tag:
-colorspace bt709 -color_primaries bt709 -color_trc bt709.