Skip to main content
A 3D LUT maps every input colour to an output colour. To apply one, you look up each pixel’s RGB value in the table and blend the nearest entries. Every sample on this page does exactly that. They all produce the same result: within 1 level out of 255 of ffmpeg’s 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 33 means 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 the TITLE and DOMAIN_* 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’s lut3d 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 has a built-in 3D LUT filter, so you only need to read the file into it.
grade.py
Load each LUT once and reuse the filter for every image. To apply the look at partial strength, blend the original and graded images:

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
The photo itself must come from your own origin, or from a server that sends CORS headers with crossorigin="anonymous" set on the <img>. Otherwise the canvas is tainted and getImageData throws.
For live video or very large images, run the same lookup in a WebGL fragment shader with the LUT uploaded as a 3D texture. The per-pixel maths is the same.

Swift (Core Image)

Core Image’s CIColorCubeWithColorSpace 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
In an app, load the .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

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.
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.
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.
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.
Using these LUTs in public work or a product? Credit slay.