Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Compositor/Document/CameraRaw.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import AppKit

/// White balance on an already-rendered layer. Raw lighting presets are absent: temperature and tint
/// are relative offsets, not kelvin.
nonisolated enum CameraRawWhiteBalance: String, CaseIterable, Sendable {
nonisolated enum CameraRawWhiteBalance: String, CaseIterable, Codable, Sendable {
case custom = "Custom"
case auto = "Auto"
}

/// Glow's three looks. Warmth tints Diffusion and Bloom from cool to warm; Halation's fringe stays red.
nonisolated enum CameraRawGlowStyle: String, CaseIterable, Sendable {
nonisolated enum CameraRawGlowStyle: String, CaseIterable, Codable, Sendable {
case diffusion = "Diffusion"
case bloom = "Bloom"
case halation = "Halation"
Expand All @@ -22,7 +22,7 @@ nonisolated enum CameraRawGlowStyle: String, CaseIterable, Sendable {
}

/// Post-crop vignette. Highlight Priority is the style whose Highlights slider protects bright pixels.
nonisolated enum CameraRawVignetteStyle: String, CaseIterable, Sendable {
nonisolated enum CameraRawVignetteStyle: String, CaseIterable, Codable, Sendable {
case highlightPriority = "Highlight Priority"
case colorPriority = "Color Priority"
case paintOverlay = "Paint Overlay"
Expand All @@ -44,7 +44,7 @@ nonisolated enum CameraRawClipping: Int32, Sendable {
}

/// Camera Raw Filter settings. Defaults leave the image unchanged.
nonisolated struct CameraRawSettings: Equatable, Sendable {
nonisolated struct CameraRawSettings: Codable, Equatable, Sendable {
static let exposureRange: ClosedRange<Double> = -5...5
static let toneRange: ClosedRange<Double> = -100...100
static let unitRange: ClosedRange<Double> = 0...100
Expand Down
10 changes: 5 additions & 5 deletions Compositor/Document/CameraRawColor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct CameraRawDrag {
}

/// Parametric regions and point curves. Amounts are −100…100. Curve points use 0…1 on both axes.
nonisolated struct CameraRawCurveSettings: Equatable, Sendable {
nonisolated struct CameraRawCurveSettings: Codable, Equatable, Sendable {
var shadows: Double = 0
var darks: Double = 0
var lights: Double = 0
Expand Down Expand Up @@ -148,7 +148,7 @@ nonisolated struct CameraRawCurveSettings: Equatable, Sendable {
}

/// Eight color families, each with hue, saturation, and luminance shifts of −100…100.
nonisolated struct CameraRawMixerSettings: Equatable, Sendable {
nonisolated struct CameraRawMixerSettings: Codable, Equatable, Sendable {
static let names = ["Reds", "Oranges", "Yellows", "Greens", "Aquas", "Blues", "Purples", "Magentas"]
static let centers = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0]
var hue = Array(repeating: 0.0, count: 8)
Expand Down Expand Up @@ -193,7 +193,7 @@ nonisolated struct CameraRawMixerSettings: Equatable, Sendable {
}

/// One picked color and how far its adjustment reaches.
nonisolated struct CameraRawPointColor: Equatable, Sendable {
nonisolated struct CameraRawPointColor: Codable, Equatable, Sendable {
var hue: Double = 0
var saturation: Double = 0
var luminance: Double = 0
Expand Down Expand Up @@ -221,7 +221,7 @@ nonisolated struct CameraRawPointColor: Equatable, Sendable {
}

/// Four color wheels plus how the three tonal wheels overlap and which end they favor.
nonisolated struct CameraRawGradingSettings: Equatable, Sendable {
nonisolated struct CameraRawGradingSettings: Codable, Equatable, Sendable {
var shadows = CameraRawGradeWheel()
var midtones = CameraRawGradeWheel()
var highlights = CameraRawGradeWheel()
Expand Down Expand Up @@ -250,7 +250,7 @@ nonisolated struct CameraRawGradingSettings: Equatable, Sendable {
}
}

nonisolated struct CameraRawGradeWheel: Equatable, Sendable {
nonisolated struct CameraRawGradeWheel: Codable, Equatable, Sendable {
var hue: Double = 0
var saturation: Double = 0
var luminance: Double = 0
Expand Down
4 changes: 2 additions & 2 deletions Compositor/Document/CameraRawDetailOptics.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import AppKit

/// Sharpening and manual noise reduction. Amount is 0…150; the rest use Camera Raw's usual 0…100 ranges.
nonisolated struct CameraRawDetailSettings: Equatable, Sendable {
nonisolated struct CameraRawDetailSettings: Codable, Equatable, Sendable {
var sharpenAmount: Double = 0
var sharpenRadius: Double = 10
var sharpenDetail: Double = 25
Expand Down Expand Up @@ -40,7 +40,7 @@ nonisolated struct CameraRawDetailSettings: Equatable, Sendable {

/// Lens profile toggles, manual distortion, defringe, and lens-vignetting correction. Profile metadata is not
/// available on a rendered layer, so the profile sliders only scale generic correction strength.
nonisolated struct CameraRawOpticsSettings: Equatable, Sendable {
nonisolated struct CameraRawOpticsSettings: Codable, Equatable, Sendable {
var removeChromaticAberration = false
var enableLensProfile = false
var profileDistortion: Double = 100
Expand Down
10 changes: 5 additions & 5 deletions Compositor/Document/CameraRawGeometryCalibration.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import AppKit
import CoreImage

nonisolated enum CameraRawUprightMode: String, CaseIterable, Sendable {
nonisolated enum CameraRawUprightMode: String, CaseIterable, Codable, Sendable {
case off = "Off"
case guided = "Guided"
}

nonisolated enum CameraRawProjection: String, CaseIterable, Sendable {
nonisolated enum CameraRawProjection: String, CaseIterable, Codable, Sendable {
case perspective = "Perspective"
case rectilinear = "Rectilinear"
}
Expand All @@ -22,7 +22,7 @@ nonisolated struct CameraRawGeometryGuide: Equatable, Sendable, Codable {
var end: CGPoint { CGPoint(x: endX, y: endY) }
}

nonisolated struct CameraRawGeometrySettings: Equatable, Sendable {
nonisolated struct CameraRawGeometrySettings: Codable, Equatable, Sendable {
var upright: CameraRawUprightMode = .off
var projection: CameraRawProjection = .perspective
var vertical: Double = 0
Expand Down Expand Up @@ -172,7 +172,7 @@ nonisolated struct CameraRawGeometrySettings: Equatable, Sendable {
}
}

nonisolated enum CameraRawProcessVersion: String, CaseIterable, Sendable {
nonisolated enum CameraRawProcessVersion: String, CaseIterable, Codable, Sendable {
case version1 = "Version 1"
case version2 = "Version 2"
case version3 = "Version 3"
Expand Down Expand Up @@ -209,7 +209,7 @@ nonisolated enum CameraRawProcessVersion: String, CaseIterable, Sendable {
}
}

nonisolated struct CameraRawCalibrationSettings: Equatable, Sendable {
nonisolated struct CameraRawCalibrationSettings: Codable, Equatable, Sendable {
var process: CameraRawProcessVersion = .version6
var shadowTint: Double = 0
var redHue: Double = 0
Expand Down
191 changes: 191 additions & 0 deletions Compositor/Document/CameraRawPresets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import Foundation

nonisolated extension CameraRawSettings {
/// These settings as a preset stores them: a JSON object, without the Geometry guide lines, which belong to
/// the image they were drawn on, or the point colors' Visualize switches, which are a view of the panel.
var presetObject: Any {
let settings = normalized.forPreset
guard let data = try? JSONEncoder().encode(settings),
let object = try? JSONSerialization.jsonObject(with: data) else { return [String: Any]() }
return object
}

/// Settings read back from a preset. The saved object is laid over the defaults first, so a preset saved before a
/// slider existed takes that slider's default, and fields this build doesn't know are ignored. Values are clamped
/// to their ranges. Nil when the object still can't be read (not an object, or a choice this build lacks).
static func preset(from object: Any) -> CameraRawSettings? {
guard var saved = object as? [String: Any], let defaults = CameraRawSettings().presetObject as? [String: Any],
JSONSerialization.isValidJSONObject(saved) else { return nil }
// List entries have defaults too: each point color is laid over a new one.
if var mixer = saved["mixer"] as? [String: Any], let points = mixer["points"] as? [Any],
let blank = (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(CameraRawPointColor()))) as? [String: Any] {
mixer["points"] = points.map { ($0 as? [String: Any]).map { merged($0, over: blank) } ?? $0 }
saved["mixer"] = mixer
}
guard let data = try? JSONSerialization.data(withJSONObject: merged(saved, over: defaults)),
var settings = try? JSONDecoder().decode(CameraRawSettings.self, from: data).forPreset else { return nil }
// The mixer's kernel reads exactly eight families from each list.
func eight(_ values: [Double]) -> [Double] { Array((values + Array(repeating: 0, count: 8)).prefix(8)) }
settings.mixer.hue = eight(settings.mixer.hue)
settings.mixer.saturation = eight(settings.mixer.saturation)
settings.mixer.luminance = eight(settings.mixer.luminance)
return settings.normalized
}

/// What of these settings a preset keeps.
var forPreset: Self {
var result = self
result.geometry.guides = []
for index in result.mixer.points.indices { result.mixer.points[index].visualize = false }
return result
}

private static func merged(_ saved: [String: Any], over defaults: [String: Any]) -> [String: Any] {
var result = defaults
for (key, value) in saved {
if let inner = value as? [String: Any], let base = defaults[key] as? [String: Any] {
result[key] = merged(inner, over: base)
} else {
result[key] = value
}
}
return result
}
}

/// A person's saved Camera Raw look, by name.
struct CameraRawPreset: Identifiable, Equatable {
let name: String
let settings: CameraRawSettings
var id: String { name }
}

nonisolated enum CameraRawPresetError: LocalizedError, Equatable {
case invalidName
case nameTaken(String)
case missing(String)

var errorDescription: String? {
switch self {
case .invalidName: return "A preset needs a name of 1 to \(CameraRawPresetStore.nameLimit) characters."
case .nameTaken(let name): return "There's already a preset called “\(name)”."
case .missing(let name): return "There's no preset called “\(name)”."
}
}
}

/// The person's Camera Raw presets. They belong to the app, not to a project: one JSON file in Application Support,
/// `{"version": 1, "presets": [{"name": …, "settings": {…}}]}`. A preset this build can't read is left out of the
/// list but written back as it was; a file that can't be read at all is set aside as `.bak` before it is replaced.
@MainActor @Observable
final class CameraRawPresetStore {
static let shared = CameraRawPresetStore(url: URL.applicationSupportDirectory.appending(path: "CameraRawPresets.json"))
nonisolated static let nameLimit = 64

let url: URL
/// Sorted by name, ignoring case.
private(set) var presets: [CameraRawPreset] = []
@ObservationIgnored private var unreadable: [[String: Any]] = []
@ObservationIgnored private var fileIsUnreadable = false

init(url: URL) {
self.url = url
reload()
}

/// `name` trimmed, or nil when that leaves nothing or more than `nameLimit` characters.
static func validName(_ name: String) -> String? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
return (1...nameLimit).contains(trimmed.count) ? trimmed : nil
}

func preset(named name: String) -> CameraRawPreset? {
presets.first { $0.name.caseInsensitiveCompare(name) == .orderedSame }
}

func reload() {
presets = []
unreadable = []
fileIsUnreadable = false
guard FileManager.default.fileExists(atPath: url.path) else { return }
guard let data = try? Data(contentsOf: url),
let file = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let entries = file["presets"] as? [Any] else { fileIsUnreadable = true; return }
var read: [CameraRawPreset] = []
for case let entry as [String: Any] in entries {
if let name = (entry["name"] as? String).flatMap(Self.validName),
let settings = entry["settings"].flatMap(CameraRawSettings.preset(from:)),
!read.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) {
read.append(CameraRawPreset(name: name, settings: settings))
} else {
unreadable.append(entry)
}
}
presets = Self.sorted(read)
}

/// Saves `settings` as `name`, replacing a preset of that name (ignoring case).
func save(_ settings: CameraRawSettings, as name: String) throws {
guard let name = Self.validName(name) else { throw CameraRawPresetError.invalidName }
var list = presets.filter { $0.name.caseInsensitiveCompare(name) != .orderedSame }
list.append(CameraRawPreset(name: name, settings: CameraRawSettings.preset(from: settings.presetObject) ?? settings))
try write(list, unreadable: unreadable.filter { !Self.named($0, name) })
}

func rename(_ name: String, to newName: String) throws {
guard let newName = Self.validName(newName) else { throw CameraRawPresetError.invalidName }
guard let preset = preset(named: name) else { throw CameraRawPresetError.missing(name) }
if let other = self.preset(named: newName), other.name != preset.name { throw CameraRawPresetError.nameTaken(other.name) }
let list = presets.map { $0.name == preset.name ? CameraRawPreset(name: newName, settings: $0.settings) : $0 }
try write(list, unreadable: unreadable)
}

func delete(_ name: String) throws {
guard let preset = preset(named: name) else { throw CameraRawPresetError.missing(name) }
try write(presets.filter { $0.name != preset.name }, unreadable: unreadable)
}

/// Writes the file first and only then takes the new list, so a failed write leaves both as they were.
private func write(_ list: [CameraRawPreset], unreadable kept: [[String: Any]]) throws {
let manager = FileManager.default
try manager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
if fileIsUnreadable, manager.fileExists(atPath: url.path) {
let backup = url.appendingPathExtension("bak")
try? manager.removeItem(at: backup)
try manager.moveItem(at: url, to: backup)
}
let entries: [[String: Any]] = list.map { ["name": $0.name, "settings": $0.settings.presetObject] } + kept
let data = try JSONSerialization.data(withJSONObject: ["version": 1, "presets": entries],
options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: .atomic)
fileIsUnreadable = false
unreadable = kept
presets = Self.sorted(list)
}

private static func sorted(_ list: [CameraRawPreset]) -> [CameraRawPreset] {
list.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}

private static func named(_ entry: [String: Any], _ name: String) -> Bool {
(entry["name"] as? String).flatMap(validName)?.caseInsensitiveCompare(name) == .orderedSame
}
}

extension EditorSession {
/// The open Camera Raw panel's sliders as Save Settings as Preset keeps them. Nil when Camera Raw isn't open.
var cameraRawPresetSettings: CameraRawSettings? {
guard let edit = filterEdit, edit.kind == .cameraRaw else { return nil }
return edit.settings.cameraRaw.forPreset
}

/// Sets every Camera Raw slider to `preset`. Cancel still takes it all back. A preset saved with White Balance >
/// Auto balances this image again, as Camera Raw does.
func applyCameraRawPreset(_ preset: CameraRawPreset) async {
guard let edit = filterEdit, edit.kind == .cameraRaw, !edit.committing else { return }
var settings = edit.settings
settings.cameraRaw = preset.settings
updateFilter(settings, preview: edit.preview)
if preset.settings.whiteBalance == .auto { await applyCameraRawAutoWhiteBalance() }
}
}
1 change: 1 addition & 0 deletions Compositor/UI/CameraRawControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct CameraRawControls: View {

var body: some View {
VStack(alignment: .leading, spacing: 10) {
CameraRawPresetMenu(session: session)
histogram
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Expand Down
Loading