lut3d filter.
Get the files first from Download the LUTs. The samples below use slay-01-amber.cube, and any of the five works the same way.
How a .cube file is laid out
A slay.cube file is plain text: a short header, then one RGB row per table entry.
slay-01-amber.cube
LUT_3D_SIZE 33means the table has 33 steps per channel, so 33³ = 35,937 rows.- Values run from 0 to 1. Scale 8-bit pixels by dividing by 255.
- Red changes fastest, then green, then blue. Row
r + g·33 + b·33²holds the output for grid point(r, g, b). - Lines starting with
#are comments, and theTITLEandDOMAIN_*lines are metadata. The parsers below skip them.
slay LUTs expect ordinary sRGB pixels, which is what JPEG, PNG and HEIC photos and most video already are. Don’t convert to linear light before applying them.
ffmpeg
ffmpeg’slut3d filter reads .cube files directly. It’s the quickest way to grade images, video and whole folders.
interp=tetrahedral is slightly smoother than the default trilinear and costs almost nothing. -c:a copy keeps the audio as it is. For the strength variant, change both 0.6 values to set how much of the look to mix in.
Python
- Pillow
- NumPy
Pillow has a built-in 3D LUT filter, so you only need to read the file into it.Load each LUT once and reuse the filter for every image. To apply the look at partial strength, blend the original and graded images:
grade.py
JavaScript
This module has no dependencies and runs in Node.js and the browser. It takes 8-bit RGB or RGBA pixels and returns graded pixels, leaving alpha as it is.lut.js
Node.js
sharp decodes the image to raw pixels and encodes the result.grade.mjs
In the browser
Draw the image to a canvas, grade its pixels, and put them back. The LUT can come straight from GitHub because the raw URLs allow cross-origin requests.preview.js
Swift (Core Image)
Core Image’sCIColorCubeWithColorSpace filter does the lookup on the GPU. It wants the table as RGBA floats in the same red-fastest order as the .cube file, so the loader adds an alpha of 1 to each row.
Grade.swift
.cube from your bundle with Bundle.main.url(forResource: "slay-01-amber", withExtension: "cube"). Use context.createCGImage(_:from:) when you need a CGImage or UIImage rather than a file.
Setting colorSpace to sRGB makes Core Image apply the cube to sRGB values, matching the other samples. Build the filter once and reuse it. For live camera preview, set inputImage on each frame and render through the same CIContext.
Troubleshooting
My output differs slightly from ffmpeg's
My output differs slightly from ffmpeg's
The LUT maths agree to within 1 level. Bigger differences usually come from JPEG decoding: ffmpeg and libjpeg-based tools turn the same JPEG into slightly different RGB values before any grading happens. Decode with the same tool, or start from a PNG, and the results match.
Colours look washed out or too dark
Colours look washed out or too dark
The pixels were probably in linear light, or in a wide colour space such as Display P3, when the LUT was applied. Convert to sRGB first and apply the LUT before any linearising step.
The parser rejects a third-party .cube file
The parser rejects a third-party .cube file
These parsers expect 3D LUTs with a 0–1 domain, which every slay LUT uses. Files with
LUT_1D_SIZE, or with DOMAIN_MIN/DOMAIN_MAX other than 0 and 1, need extra handling.Grading is slow
Grading is slow
Parse each LUT once and cache it. In Python, grade a whole frame with the NumPy version rather than looping over pixels. In the browser or on iOS, use the GPU: WebGL or Core Image.