Implementing LUT Loading and the Color Grading Pipeline in Palmier Pro

Palmier Pro implements a modular, cache-driven color grading pipeline that parses .cube LUT files into RGBA Float32 buffers, applies them through a custom Metal tetrahedral interpolation kernel, and exposes the effect through both the Adjust Tab inspector and the Agent tool chain.

The color grading pipeline in Palmier Pro centers on three tightly-coupled layers: file parsing, GPU kernel execution, and effect registration. By chaining LUTLoader, LUTTetraKernel, and EffectRegistry, the app delivers persistent, thread-safe LUT storage and real-time Metal-accelerated interpolation. This article walks through the source code to show exactly how Palmier Pro implements LUT loading and the color grading pipeline.

Parsing and Caching .cube Files with LUTLoader

All LUT file handling in Palmier Pro begins in Sources/PalmierPro/Compositing/LUTLoader.swift. The **LUTLoader** class reads 3-D .cube files, validates their headers, normalizes color domains, and packs the data into an RGBA Float32 buffer suitable for Core Image.

The parser strictly enforces format constraints. It rejects 1-D LUTs and caps the dimension at 128. Domain values defined by DOMAIN_MIN and DOMAIN_MAX are clamped to [0, 1], and an alpha channel of 1 is appended to every entry to produce a fully opaque RGBA buffer.

Once parsed, the store(path:projectId:) helper copies the source file into the app-support luts directory so the LUT survives project moves. The resulting CubeLUT object is then inserted into a thread-safe dictionary named cachedLUTs that is keyed by the absolute file path. Because the cache is immutable after insertion, the code uses nonisolated(unsafe) paired with a global NSLock to protect concurrent reads and writes.

let lutPath = "/Users/me/Lookups/film.cube"
do {
    // Move the file into the project’s LUT folder and cache it
    let storedPath = try LUTLoader.store(path: lutPath, projectId: editor.projectId)
    // Retrieve the parsed LUT for later use
    guard let cube = LUTLoader.load(path: storedPath) else { fatalError("Failed to load LUT") }
    // Apply it to a CIImage with full intensity
    let graded = LUTTetraKernel.apply(inputImage, cube: cube, key: storedPath, intensity: 1.0)
} catch {
    print("LUT error: \(error)")
}

GPU-Accelerated Tetrahedral Interpolation in LUTTetraKernel

After parsing, the actual color transformation runs on the GPU inside Sources/PalmierPro/Compositing/Kernels/LUTTetraKernel.swift. **LUTTetraKernel** wraps a custom Metal kernel called lutTetra through CIKernelLoader.

For each frame, the kernel receives the source CIImage, a LUT image created from the RGBA buffer, the LUT dimension, and an intensity factor. A dedicated ROI callback ensures the LUT image is only read where needed, which avoids expensive per-frame re-uploads. A small per-LUT image cache managed by Cache.store holds up to 16 entries, guaranteeing fast reuse when identical LUTs are applied across multiple clips.

The intensity parameter is a floating-point value in the range [0, 1]. It blends the LUT result with the underlying grade, giving users precise control over the strength of the color grade.

EffectRegistry.register(
    id: "color.lut",
    displayName: "LUT",
    category: "Color"
) { parameters, image in
    guard let path = parameters.string("path"),
          let cube = LUTLoader.load(path: path) else { return image }
    return LUTTetraKernel.apply(image, cube: cube, key: path,
                                intensity: parameters.value("intensity"))
}

Effect Registration and UI Integration

The color-grading effect is registered in Sources/PalmierPro/Compositing/EffectRegistry.swift under the identifier color.lut. When the compositing engine evaluates the effect, it loads the stored LUT path via LUTLoader.load and passes the resulting CubeLUT to LUTTetraKernel.apply.

In the inspector, Sources/PalmierPro/Inspector/Tabs/AdjustTab.swift renders a Choose LUT button. Clicking it triggers an NSOpenPanel through chooseLUT(clips:), calls LUTLoader.store, and creates an undoable edit via commitEffects. The Agent tool chain, defined in ToolDefinitions.swift and implemented in Sources/PalmierPro/Agent/Tools/ToolExecutor+Color.swift, validates and stores LUT files before invoking the effect. This maintains one consistent code path for both UI-driven and script-driven workflows.

Button {
    chooseLUT(clips: selectedClips)               // shows an NSOpenPanel
} label: {
    Label("Choose LUT", systemImage: "cube")
}

On the Agent side, validation is wrapped in explicit error handling:

func storeLUT(_ path: String) throws -> String {
    do {
        return try LUTLoader.store(path: path, projectId: editor.projectId)
    } catch let e as LUTStoreError {
        throw ToolError(e.errorDescription ?? "Invalid LUT.")
    }
}

Test coverage for the pipeline lives in Tests/PalmierProTests/Rendering/LUTLoaderTests.swift and LUTTetraKernelTests.swift, which validate parsing edge cases and kernel behavior.

Summary

  • LUTLoader in LUTLoader.swift parses .cube files, enforces a maximum 3-D dimension of 128, normalizes domains to [0, 1], and caches immutable CubeLUT instances behind an NSLock.
  • LUTTetraKernel in LUTTetraKernel.swift performs tetrahedral interpolation through a custom Metal kernel, reuses LUT images via a 16-entry cache, and respects an intensity slider for blending.
  • EffectRegistry wires the color.lut identifier into the compositing pipeline, while AdjustTab.swift and the Agent tools in ToolExecutor+Color.swift share the same storage and validation logic.

Frequently Asked Questions

How does Palmier Pro parse .cube LUT files?

LUTLoader reads the file line by line, validates the LUT_3D_SIZE header, rejects 1-D LUTs, clamps DOMAIN_MIN and DOMAIN_MAX to [0, 1], and packs the lattice into a contiguous RGBA Float32 buffer. The parsed result is stored as a CubeLUT object that can be fed directly into Core Image or Metal shaders.

What interpolation method does the color grading pipeline use?

The pipeline uses tetrahedral interpolation executed on the GPU. LUTTetraKernel dispatches the lutTetra Metal kernel through CIKernelLoader, sampling the 3-D lattice with a supplied intensity factor that blends the transformed color back toward the original image.

How does Palmier Pro keep the LUT cache thread-safe?

A global NSLock guards the cachedLUTs dictionary in LUTLoader.swift. Because entries are immutable after insertion, the code marks the cache nonisolated(unsafe), allowing concurrent reads once the lock is released while still preventing data races during writes.

Can LUTs be applied programmatically through the Agent tool chain?

Yes. The Agent pipeline in ToolExecutor+Color.swift exposes tools that validate incoming paths through LUTLoader.store, persist the file in the project’s LUT directory, and return a storable path. That path is later resolved by EffectRegistry when the color.lut effect is evaluated, ensuring UI and scripted workflows behave identically.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →