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.