Filmtone / Color Pipeline
Filmtone's two-lane LUT pipeline implementation — the inputLut/creativeLut data model and intensity blending via CIColorMatrix
Filmtone holds `.cube` LUTs in two slots: `inputLut` (Source Profile / Camera) and `creativeLut` (Look). The data model is `ParsedCubeLutDTO { title, size, data, intensity }` and `SerializableLutDTO { size, data, intensity }`. The export order is fixed: `input LUT → base grade → tone compression → edge optics → glow → vignette → grain → creative LUT → print`. When `intensity < 0.999`, results blend linearly via `CIColorMatrix` (source) over `CISourceOverCompositing` (LUT output); above that, the LUT output passes through. On source replacement, `inputLut` clears but `creativeLut` is preserved.
- Published
- April 29, 2026
- Topics
- Filmtone · LUT · Color Pipeline · Architecture
Splitting Source Profile and Look into two slots
Filmtone holds .cube LUTs in two slots. inputLut is source-specific: camera transforms, Apple Log to Rec.709, anything tied to the material. creativeLut is look-specific: the creative / stylistic LUT layered on top of a Filmtone preset. With one slot, swapping cameras either wipes the look or forces you to give up the camera transform to keep the look. Two slots let both responsibilities coexist.
Data model
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneMediaTypes.swift
struct ParsedCubeLutDTO: Codable {
let title: String
let size: Int
let data: [Double]
let intensity: Double
}
struct SerializableLutDTO: Codable {
let size: Int
let data: [Double]
let intensity: Double
}
struct Phase0ExportRequestDTO: Codable {
let sourceUri: String
// ...
let lut: ParsedCubeLutDTO? // legacy, decode-only
let inputLut: SerializableLutDTO? // Source Profile / Camera
let creativeLut: SerializableLutDTO? // Look
// ...
}The legacy lut field stays for decode compatibility only. Current export-request builds set lut: nil and transport inputLut and creativeLut independently. FilmtonePhase0Math.buildExportRequest enforces this, and verify:swift-contract's Phase0 fixtures verify it on every gate.
Export order is fixed
- input LUT — Source Profile / Camera transform
- base grade — the Filmtone preset core (params)
- tone compression
- edge optics — lens softness, RGB shift
- glow family — bloom, halation, diffusion
- vignette
- grain
- creative LUT — the look's final pass
- print — the last touch of paper-print character
Input lands first (the source enters Filmtone's working space) and creative lands last (the look sits on top as the final layer). The ordering is fixed and the same in preview and export; verify:swift-contract keeps them in lockstep.
Replacement preserves look, not source profile
When the source is replaced (a different video, a different camera), inputLut is dropped — the new material likely needs a different transform. creativeLut is preserved because look should be independent of material. clearInputLut() and clearCreativeLut() are completely independent APIs; neither pulls the other along.
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneEditorStore.swift
func clearInputLut() {
applyLutMutation { $0.inputLut = nil }
}
func clearCreativeLut() {
applyLutMutation { $0.creativeLut = nil }
}Funneling all LUT mutation through one helper
Every LUT change goes through applyLutMutation. It updates updatedAt, invalidates the rendered output state (preview task, compare frame, export result, export progress) in one shot, persists, and reschedules a preview render. Setters (setInputLutIntensity, setCreativeLutIntensity) call the same helper, so callers don't have to track the side-effect cascade themselves.
private func applyLutMutation(_ mutate: (inout FilmtoneProjectState) -> Void) {
mutate(&project)
project.updatedAt = FilmtonePhase0Math.isoTimestamp()
invalidateRenderedOutputState()
persist()
schedulePreviewRender()
}
func setInputLutIntensity(_ intensity: Double) {
let clamped = FilmtonePhase0Math.clampLutIntensity(intensity)
guard let current = project.inputLut, current.intensity != clamped else { return }
applyLutMutation {
guard let lut = $0.inputLut else { return }
$0.inputLut = lut.withIntensity(clamped)
}
}Linear intensity blending in Core Image
FilmtoneExportSession.applyLut returns the LUT result directly when PreparedLut.intensity >= 0.999. Below that, it scales alpha to intensity via CIColorMatrix, then composites the alpha-scaled LUT image over the source via CISourceOverCompositing. The result is lut * intensity + original * (1 - intensity). The >= 0.999 early return keeps the common 100% path off the compositing cost.
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneExportSession.swift
private func applyLut(_ lut: PreparedLut, to image: CIImage) -> CIImage {
let lutImage = image.applyingFilter("CIColorCubeWithColorSpace", parameters: [
"inputCubeDimension": lut.size,
"inputCubeData": lut.cubeData,
"inputColorSpace": outputColorSpace,
])
guard lut.intensity < 0.999 else { return lutImage }
let alphaAdjusted = lutImage.applyingFilter("CIColorMatrix", parameters: [
"inputAVector": CIVector(x: 0, y: 0, z: 0, w: lut.intensity),
])
return alphaAdjusted
.applyingFilter("CISourceOverCompositing", parameters: [
kCIInputBackgroundImageKey: image,
])
.cropped(to: image.extent)
}How intensity travels from UI to rendering
SwiftUI Slider → setInputLutIntensity(value) → clampLutIntensity(0...1) → ParsedCubeLutDTO.withIntensity(clamped) → applyLutMutation → schedulePreviewRender → preview rebuild. Export request reads the same project state, and makePreparedLut(from: SerializableLutDTO?) carries PreparedLut.intensity straight into applyLut. Nobody can override intensity midstream — there's no second source of truth.