A macOS app that applies .cube LUTs to RAW and other images. It can also build a LUT from a RAW + JPEG pair.
SwiftUI and Core Image, no third-party packages. macOS 26 to run, Xcode 27 to compile.
- RAW via
CIRAWFilter, not the embedded preview. DNG, CR2, CR3, NEF, ARW, ORF, RAF, RW2, PEF, SRW, X3F, RAW. JPEG, PNG, TIFF, BMP, HEIC. - Import — drop a file or a folder. ⌘O file · ⌘⇧I Photos (max 50) · ⌘⌥I source folder.
- LUTs —
.cube3D (LUT_3D_SIZE,DOMAIN_MIN/DOMAIN_MAX) throughCIColorCubeWithColorSpaceon Metal. Sidebar scans recursively, groups by subfolder, searchable. Intensity 0–100%. ⌘⇧L picks the folder. - Preview — V side-by-side / single. Hold Space in single view for the original. ↑ ↓ walk the library.
- Inspector ⌘I
- Info — histogram of what's on screen (graded, or original while Space is down) plus EXIF / TIFF / GPS
- Develop — the
CIRAWFilterknobs this file's decoder actually supports - Adjust — nine sliders after develop, before the LUT: exposure, brightness, contrast, saturation, highlights, shadows, temperature, tint, vibrance
- Filmstrip when a source folder is open. ← → or [ ] step through; the current look stays on. ⌘R rescans.
- Export — 16-bit TIFF, JPEG (quality 0.95), or PNG, always full resolution, named
{photo}_{LUT}.ext(spaces in the LUT name become underscores). ⌘⇧E Export All writes the whole look — develop, adjustments, LUT, intensity — and counts failures instead of aborting.
⌘D — File ▸ Derive LUT from JPG…. Pick the RAW, pick the JPEG, hit Derive.
The JPEG is treated as a look (the manufacturer's color science, or whatever picture profile was on). LUTzy writes the difference against a neutral RAW develop. Same frame required — aspect within 1%. Pixel size can differ.
RAW ──► CIRAWFilter (neutral) ─┐
├─► align ─► smooth samples ─► 33³ cube ─┬─► .cube
JPEG ─► decode ─► edge mask ────┘ └─► report
The result previews on the current image and stays in memory until Save to LUT Folder….
The report is a tone curve (R/G/B vs identity) plus saturation, sharpening, coverage, samples, alignment, and camera EXIF. Sharpening is measured, not applied — a cube can't sharpen, and there isn't a second stage that does.
How the cube is built
The RAW is developed with the same default CIRAWFilter settings the rest of the app uses, so the LUT applies without a baseline mismatch. Both images are Lanczos-scaled onto a shared working extent (long edge capped at 3000 px — 200k samples don't get better from a 60 MP buffer) and aligned by luma cross-correlation. An edge mask on the JPEG keeps in-camera sharpening out of the color samples. Surviving pixels (~200k, from a 2M draw) fill a 33³ cube; empty cells are pulled from neighbors, then identity.
Preview
| Key | Action |
|---|---|
| ↑ ↓ | previous / next LUT |
| ← → or [ ] | previous / next image |
| Space (hold) | original, in single view |
| V | side-by-side / single |
| ⌘I | inspector |
File
| Key | Action |
|---|---|
| ⌘O | open image |
| ⌘⇧I | Photos |
| ⌘⌥I | source folder |
| ⌘R | rescan source folder |
| ⌘⇧L | LUT folder |
| ⌘D | derive |
| ⌘S | export |
| ⌘⇧E | export all |
| ⌘, | settings — launch defaults and the sidebar's collapsed folders |
Letter keys go through SwiftUI's .onKeyPress on the split view; the preview canvas is focusable and holds focus by default so the handler always has a focused descendant. ⌘ shortcuts go through the menu bar.
swift run # launch
open Package.swift # same binary, Xcode debugger
swift testImportant
swift run and Run from Xcode both produce a SwiftPM executable, not a sandboxed .app. LUT folder and source folder do not persist across launches.
There is no .xcodeproj. Package.swift excludes Assets.xcassets and LUTzy.entitlements; the appiconset is empty; there is no Info.plist or bundle identifier. The entitlements file is real (sandbox, user-selected files, app-scoped bookmarks) and unused. An Xcode app target would apply it. This repo doesn't have one.
- Run — macOS 26
- Compile — Xcode 27 / macOS 27 SDK
Deployment target and SDK are different things. The compiler rejects API newer than 26 unless it's #available(macOS 27, *)-guarded, and a macOS 27 symbol has to be in the SDK before it can be referenced at all — #available does not conjure a missing symbol — so Xcode 26 can't build the package. The binary still runs on 26.
swift test generates fixtures into a temp directory. Tests that need a real RAW/JPEG pair look in realworldtest/ (gitignored) and skip if it isn't there. LUTZY_BENCH=1 for the preview-cost tests. CI is debug build → test → release build on GitHub's xcode-27 runner.
Layout and render path
Everything of substance is in Sources/LUTzyKit. Sources/LUTzy is @main plus an AppDelegate that forces .regular activation, because a bare executable otherwise starts as a background process with no Dock icon. Only ContentView and LUTzyCommands are public, so the kit can be @testable imported.
The look is an EditDocument (develop + adjustments + LUT). Preview, histogram, and both export paths render that document through one RenderEngine actor / one CIContext. Preview vs export differs only by scale: 1600×1200 vs full. WorkingSpace (sRGB) is used for both cube interpolation and encoding. Non-RAW files go through ImageDecoder.orientedLoadOptions because CIImage(contentsOf:) ignores EXIF orientation and CIRAWFilter doesn't.
Swift 6 language mode on every target. No @unchecked Sendable, nonisolated(unsafe), or @preconcurrency.
Pipeline notes: docs/PHASE2_SPEC.md. Standing review: docs/CODE_REVIEW.md.