Filmtone / Color Pipeline
Filmtone の2系統 LUT パイプライン実装 — `inputLut` / `creativeLut` のデータモデルと CIColorMatrix による intensity ブレンド
Filmtone は `.cube` LUT を `inputLut` (Source Profile / Camera) と `creativeLut` (Look) の 2 スロットで保持する。データモデルは `ParsedCubeLutDTO { title, size, data, intensity }` と `SerializableLutDTO { size, data, intensity }`。書き出し順は `input LUT → base grade → tone compression → edge optics → glow → vignette → grain → creative LUT → print` で固定。`intensity < 0.999` の時は `CIColorMatrix` (元画像) と `CISourceOverCompositing` (LUT 結果) で線形ブレンドし、`>= 0.999` なら LUT 結果をそのまま返す。素材差し替え時は `inputLut` だけ消し、`creativeLut` は保持する。
- Published
- 2026年4月29日
- Topics
- Filmtone · LUT · Color Pipeline · Architecture
Source Profile と Look を 2 スロットに分ける
Filmtone は `.cube` LUT を 2 スロットで保持する。`inputLut` は素材依存 (Camera transform / Apple Log to Rec.709 など、source-specific な変換)、`creativeLut` は look 依存 (Filmtone preset の上に乗せる creative / stylistic LUT)。1 スロットだけだとカメラを差し替えた瞬間に look まで失うか、look を維持するために camera transform を諦めるかの二択になる。
データモデル
// 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
// ...
}legacy `lut` field は decode 互換だけのために残している。current export request build では `lut: nil` を明示し、`inputLut` / `creativeLut` を別々に transport する。`FilmtonePhase0Math.buildExportRequest` がこの不変条件を担保していて、`verify:swift-contract` の Phase0 fixtures がそれを検証する。
書き出し順は固定
- input LUT — Source Profile / Camera transform
- base grade — Filmtone preset の core (params)
- tone compression
- edge optics — lens softness, RGB shift
- glow family — bloom, halation, diffusion
- vignette
- grain
- creative LUT — look の最終仕上げ
- print — 印画紙質感の最後のひと押し
input は最初 (素材を Filmtone の作業空間に持ち込む)、creative は最後 (look を最終層として乗せる) という配置は、preset の意味と矛盾しない順序として固定。preview と export で順序が揃うことを `verify:swift-contract` が監視する。
素材差し替え時の保持/破棄ルール
素材を別動画/写真に差し替えた瞬間、`inputLut` は破棄する (新しい素材は別カメラ / 別 transform の可能性が高い)。`creativeLut` は保持する (look は素材から独立)。`clearInputLut()` と `clearCreativeLut()` は完全に独立した API で、片方が片方を巻き込まない。
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneEditorStore.swift
func clearInputLut() {
applyLutMutation { $0.inputLut = nil }
}
func clearCreativeLut() {
applyLutMutation { $0.creativeLut = nil }
}Store mutation を 1 ヘルパーに集約
LUT の変更経路は `applyLutMutation` を必ず通る。これが `updatedAt` を更新し、preview task / compare frame / export result / export progress のような `rendered output state` をまとめて invalidate し、persist し、preview render を再スケジュールする。setter (`setInputLutIntensity` / `setCreativeLutIntensity`) も同じヘルパーを使うので、UI 側は副作用を意識せずに値だけ落とせる。
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)
}
}intensity の線形ブレンド (Core Image)
`FilmtoneExportSession.applyLut` は `PreparedLut.intensity >= 0.999` の時に LUT 結果をそのまま返し、それ未満の時は `CIColorMatrix` で alpha を `intensity` に絞ったあと `CISourceOverCompositing` で元画像と合成する。結果としては `lut * intensity + original * (1 - intensity)` の線形ブレンド。`>= 0.999` の早期 return が、100% 想定の通常パスから不要な合成コストを取り除く。
// 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)
}intensity が UI から rendering まで届くルート
SwiftUI の Slider → `setInputLutIntensity(value)` → `clampLutIntensity(0...1)` → `ParsedCubeLutDTO.withIntensity(clamped)` → `applyLutMutation` → `schedulePreviewRender` → preview 再生成。export request 側も同じ project state を読むので、`makePreparedLut(from: SerializableLutDTO?)` の `PreparedLut.intensity` がそのまま `applyLut` に届く。途中で誰かが intensity を別の場所に上書きできない構造になっている。