From f475217e4c5cedc6542deb7acf9226c5170329a7 Mon Sep 17 00:00:00 2001 From: Fvzion Date: Tue, 29 Sep 2026 17:33:53 -0600 Subject: [PATCH] Camera Raw presets: save the panel's settings under a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Presets menu at the top of the Camera Raw panel lists the person's saved looks and applies one when chosen (Cancel still takes it back). Save Settings as Preset… asks for a name and before replacing one; Rename and Delete act on one preset each. A preset saved with White Balance › Auto balances each image it's applied to. Geometry guide lines, which belong to one image, stay out. Presets live in one JSON file in Application Support, not in projects. They are read over the defaults, so one saved before a slider existed still opens; one this build can't read is kept in the file, and an unreadable file is set aside as .bak. Co-Authored-By: Claude Opus 5.5 --- Compositor/Document/CameraRaw.swift | 8 +- Compositor/Document/CameraRawColor.swift | 10 +- .../Document/CameraRawDetailOptics.swift | 4 +- .../CameraRawGeometryCalibration.swift | 10 +- Compositor/Document/CameraRawPresets.swift | 191 +++++++++++++++ Compositor/UI/CameraRawControls.swift | 1 + Compositor/UI/CameraRawPresetMenu.swift | 104 +++++++++ CompositorTests/CameraRawPresetTests.swift | 219 ++++++++++++++++++ README.md | 2 +- 9 files changed, 532 insertions(+), 17 deletions(-) create mode 100644 Compositor/Document/CameraRawPresets.swift create mode 100644 Compositor/UI/CameraRawPresetMenu.swift create mode 100644 CompositorTests/CameraRawPresetTests.swift diff --git a/Compositor/Document/CameraRaw.swift b/Compositor/Document/CameraRaw.swift index f7ede66a9..949114b83 100644 --- a/Compositor/Document/CameraRaw.swift +++ b/Compositor/Document/CameraRaw.swift @@ -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" @@ -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" @@ -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 = -5...5 static let toneRange: ClosedRange = -100...100 static let unitRange: ClosedRange = 0...100 diff --git a/Compositor/Document/CameraRawColor.swift b/Compositor/Document/CameraRawColor.swift index f97b45a48..642df56cc 100644 --- a/Compositor/Document/CameraRawColor.swift +++ b/Compositor/Document/CameraRawColor.swift @@ -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 @@ -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) @@ -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 @@ -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() @@ -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 diff --git a/Compositor/Document/CameraRawDetailOptics.swift b/Compositor/Document/CameraRawDetailOptics.swift index 99f82f963..45c0a138f 100644 --- a/Compositor/Document/CameraRawDetailOptics.swift +++ b/Compositor/Document/CameraRawDetailOptics.swift @@ -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 @@ -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 diff --git a/Compositor/Document/CameraRawGeometryCalibration.swift b/Compositor/Document/CameraRawGeometryCalibration.swift index 6eca7819c..b7b0266b0 100644 --- a/Compositor/Document/CameraRawGeometryCalibration.swift +++ b/Compositor/Document/CameraRawGeometryCalibration.swift @@ -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" } @@ -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 @@ -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" @@ -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 diff --git a/Compositor/Document/CameraRawPresets.swift b/Compositor/Document/CameraRawPresets.swift new file mode 100644 index 000000000..0dd6e905e --- /dev/null +++ b/Compositor/Document/CameraRawPresets.swift @@ -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() } + } +} diff --git a/Compositor/UI/CameraRawControls.swift b/Compositor/UI/CameraRawControls.swift index 809dd0548..6ee268fdd 100644 --- a/Compositor/UI/CameraRawControls.swift +++ b/Compositor/UI/CameraRawControls.swift @@ -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) { diff --git a/Compositor/UI/CameraRawPresetMenu.swift b/Compositor/UI/CameraRawPresetMenu.swift new file mode 100644 index 000000000..5ed3301c6 --- /dev/null +++ b/Compositor/UI/CameraRawPresetMenu.swift @@ -0,0 +1,104 @@ +import SwiftUI + +/// The Presets menu at the top of the Camera Raw panel: the person's saved looks, and Save, Rename and Delete. +struct CameraRawPresetMenu: View { + @Bindable var session: EditorSession + var store: CameraRawPresetStore = .shared + + /// What saving under a typed name does. + enum SaveStep: Equatable { + case invalidName + case save(String) + /// A preset of that name (ignoring case) exists: ask first, then save as `name`. + case confirmReplace(existing: String, name: String) + } + + static func saveStep(for typed: String, in store: CameraRawPresetStore) -> SaveStep { + guard let name = CameraRawPresetStore.validName(typed) else { return .invalidName } + if let existing = store.preset(named: name) { return .confirmReplace(existing: existing.name, name: name) } + return .save(name) + } + + private enum Naming: Equatable { + case save + case rename(String) + } + + @State private var naming: Naming? + @State private var typed = "" + @State private var replacing: (existing: String, name: String)? + @State private var deleting: String? + @State private var failure: String? + + var body: some View { + Menu { + if store.presets.isEmpty { Text("No presets yet") } + ForEach(store.presets) { preset in + Button(preset.name) { Task { await session.applyCameraRawPreset(preset) } } + } + Divider() + Button("Save Settings as Preset…") { typed = ""; naming = .save } + if !store.presets.isEmpty { + Menu("Rename") { + ForEach(store.presets) { preset in Button(preset.name) { typed = preset.name; naming = .rename(preset.name) } } + } + Menu("Delete") { + ForEach(store.presets) { preset in Button(preset.name) { deleting = preset.name } } + } + } + } label: { + Label("Presets", systemImage: "square.stack") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Save these settings under a name, or apply a saved look") + .disabled(session.filterEdit?.committing != false) + .alert(namingTitle, isPresented: Binding(get: { naming != nil }, set: { if !$0 { naming = nil } })) { + TextField("Name", text: $typed) + Button(naming == .save ? "Save" : "Rename") { finishNaming() } + Button("Cancel", role: .cancel) {} + } + .alert("Replace “\(replacing?.existing ?? "")”?", isPresented: Binding(get: { replacing != nil }, set: { if !$0 { replacing = nil } })) { + Button("Replace") { if let replacing { save(as: replacing.name) } } + Button("Cancel", role: .cancel) {} + } message: { Text("A preset with that name already exists. Replacing it keeps these settings instead.") } + .alert("Delete “\(deleting ?? "")”?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } })) { + Button("Delete", role: .destructive) { if let deleting { run { try store.delete(deleting) } } } + Button("Cancel", role: .cancel) {} + } message: { Text("This can't be undone.") } + .alert("Couldn't change the presets", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { + Button("OK", role: .cancel) {} + } message: { Text(failure ?? "") } + } + + private var namingTitle: String { + if case .rename(let name) = naming { return "Rename “\(name)”" } + return "Save Settings as Preset" + } + + private func finishNaming() { + switch naming { + case .save: + switch Self.saveStep(for: typed, in: store) { + case .invalidName: failure = CameraRawPresetError.invalidName.errorDescription + case .save(let name): save(as: name) + // Shown once the naming alert has gone. + case .confirmReplace(let existing, let name): Task { replacing = (existing, name) } + } + case .rename(let old): run { try store.rename(old, to: typed) } + case nil: break + } + } + + private func save(as name: String) { + guard let settings = session.cameraRawPresetSettings else { return } + run { try store.save(settings, as: name) } + } + + private func run(_ change: () throws -> Void) { + do { try change() } catch { + let message = error.localizedDescription + Task { failure = message } + } + } +} diff --git a/CompositorTests/CameraRawPresetTests.swift b/CompositorTests/CameraRawPresetTests.swift new file mode 100644 index 000000000..9219cd718 --- /dev/null +++ b/CompositorTests/CameraRawPresetTests.swift @@ -0,0 +1,219 @@ +import AppKit +import SwiftUI +import Testing +@testable import Compositor + +@MainActor +struct CameraRawPresetTests { + /// Something moved in every group, so a field left out of the preset shows up as a difference. + private func edited() -> CameraRawSettings { + var settings = CameraRawSettings() + settings.whiteBalance = .custom + settings.temperature = 12; settings.tint = -8; settings.exposure = 0.7; settings.contrast = 15 + settings.highlights = -30; settings.shadows = 25; settings.whites = 5; settings.blacks = -5 + settings.vibrance = 20; settings.saturation = -10; settings.texture = 10; settings.clarity = 12; settings.dehaze = 4 + settings.glow = 30; settings.glowStyle = .bloom; settings.glowRange = 10; settings.glowSpread = -5; settings.glowWarmth = 7 + settings.vignetteAmount = -20; settings.vignetteStyle = .paintOverlay; settings.vignetteMidpoint = 40 + settings.vignetteRoundness = 10; settings.vignetteFeather = 60; settings.vignetteHighlights = 5 + settings.grainAmount = 15; settings.grainSize = 30; settings.grainRoughness = 40 + settings.curve.shadows = 10; settings.curve.rgb = CameraRawCurveSettings.mediumContrast; settings.curve.refineSaturation = 20 + settings.mixer.hue[2] = 15; settings.mixer.saturation[5] = -20; settings.mixer.luminance[0] = 10 + settings.mixer.points = [CameraRawPointColor(hue: 30, saturation: 0.5, luminance: 0.5, hueShift: 10)] + settings.grading.shadows = CameraRawGradeWheel(hue: 200, saturation: 20, luminance: -5); settings.grading.balance = 10 + settings.detail.sharpenAmount = 40; settings.detail.noiseLuminance = 20 + settings.optics.enableLensProfile = true; settings.optics.distortion = 5; settings.optics.purpleAmount = 10 + settings.geometry.vertical = 10; settings.geometry.projection = .rectilinear; settings.geometry.constrainCrop = true + settings.calibration.process = .version6; settings.calibration.redHue = 12 + return settings.normalized + } + + @Test func presetsRoundTripWithoutGuides() throws { + var settings = edited() + settings.geometry.upright = .guided + settings.geometry.guides = [CameraRawGeometryGuide(startX: 0.1, startY: 0.1, endX: 0.1, endY: 0.9)] + // Showing which pixels a point color picks is a view of the panel, not part of the look. + settings.mixer.points[0].visualize = true + let data = try JSONSerialization.data(withJSONObject: settings.presetObject) + let read = try #require(CameraRawSettings.preset(from: try JSONSerialization.jsonObject(with: data))) + var expected = settings + expected.geometry.guides = [] + expected.mixer.points[0].visualize = false + #expect(read == expected) + } + + @Test func missingFieldsTakeTheirDefaultsAndUnknownOnesAreIgnored() throws { + var object = try #require(edited().presetObject as? [String: Any]) + object["exposure"] = nil + object["grading"] = nil + object["aSliderFromTheFuture"] = 42 + let read = try #require(CameraRawSettings.preset(from: object)) + #expect(read.exposure == 0) + #expect(read.grading == CameraRawGradingSettings()) + #expect(read.contrast == 15, "the rest is kept") + } + + /// A point color saved before one of its fields existed takes that field's default, like any other slider. + @Test func pointColorsMissingAFieldKeepTheirDefaults() throws { + var object = try #require(edited().presetObject as? [String: Any]) + var mixer = try #require(object["mixer"] as? [String: Any]) + var point = try #require((mixer["points"] as? [[String: Any]])?.first) + point["hueRange"] = nil + mixer["points"] = [point] + object["mixer"] = mixer + let read = try #require(CameraRawSettings.preset(from: object)) + #expect(read.mixer.points.first?.hueShift == 10) + #expect(read.mixer.points.first?.hueRange == CameraRawPointColor().hueRange) + } + + @Test func readingRepairsWhatWouldBreakTheFilter() throws { + var object = try #require(CameraRawSettings().presetObject as? [String: Any]) + object["exposure"] = 99 + var mixer = try #require(object["mixer"] as? [String: Any]) + mixer["hue"] = [10, 20, 30] + object["mixer"] = mixer + let read = try #require(CameraRawSettings.preset(from: object)) + #expect(read.exposure == 5) + #expect(read.mixer.hue == [10, 20, 30, 0, 0, 0, 0, 0] && read.mixer.saturation.count == 8 && read.mixer.luminance.count == 8) + #expect(CameraRawSettings.preset(from: [1, 2, 3]) == nil) + object["glowStyle"] = "Sparkle" + #expect(CameraRawSettings.preset(from: object) == nil, "an unknown choice can't be read") + } + + private func storeURL() throws -> URL { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("CameraRawPresetTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + return folder.appendingPathComponent("CameraRawPresets.json") + } + + @Test func savedPresetsComeBackSortedByName() throws { + let url = try storeURL() + let store = CameraRawPresetStore(url: url) + #expect(store.presets.isEmpty, "no file yet") + try store.save(edited(), as: " warm portrait ") + try store.save(CameraRawSettings(), as: "Base") + let reopened = CameraRawPresetStore(url: url) + #expect(reopened.presets.map(\.name) == ["Base", "warm portrait"], "trimmed, sorted ignoring case") + #expect(reopened.preset(named: "WARM PORTRAIT")?.settings == edited()) + } + + @Test func aNameDifferingOnlyInCaseReplaces() throws { + let store = CameraRawPresetStore(url: try storeURL()) + try store.save(CameraRawSettings(), as: "Portrait") + try store.save(edited(), as: "portrait") + #expect(store.presets.map(\.name) == ["portrait"]) + #expect(store.presets.first?.settings == edited()) + } + + @Test func renameAndDelete() throws { + let store = CameraRawPresetStore(url: try storeURL()) + try store.save(CameraRawSettings(), as: "One") + try store.save(edited(), as: "Two") + #expect(throws: CameraRawPresetError.self) { try store.rename("One", to: "two") } + try store.rename("One", to: "one again") + try store.rename("Two", to: "TWO") + #expect(store.presets.map(\.name) == ["one again", "TWO"], "renaming to the same name in other case is allowed") + try store.delete("one again") + #expect(store.presets.map(\.name) == ["TWO"]) + #expect(CameraRawPresetStore.validName(" ") == nil) + #expect(CameraRawPresetStore.validName(String(repeating: "a", count: 65)) == nil) + #expect(CameraRawPresetStore.validName(" Film ") == "Film") + #expect(throws: CameraRawPresetError.self) { try store.save(CameraRawSettings(), as: "") } + } + + @Test func unreadablePresetsAreKeptInTheFile() throws { + let url = try storeURL() + let file: [String: Any] = ["version": 1, "presets": [ + ["name": "From the future", "settings": ["glowStyle": "Sparkle"]], + ["name": "Old", "settings": ["exposure": 1]], + ]] + try JSONSerialization.data(withJSONObject: file).write(to: url) + let store = CameraRawPresetStore(url: url) + #expect(store.presets.map(\.name) == ["Old"]) + #expect(store.presets.first?.settings.exposure == 1) + try store.save(CameraRawSettings(), as: "New") + let saved = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + let names = (saved["presets"] as? [[String: Any]])?.compactMap { $0["name"] as? String } + #expect(names?.contains("From the future") == true, "a preset this build can't read survives") + } + + @Test func aCorruptFileIsSetAsideNotOverwritten() throws { + let url = try storeURL() + try Data("not json".utf8).write(to: url) + let store = CameraRawPresetStore(url: url) + #expect(store.presets.isEmpty) + try store.save(CameraRawSettings(), as: "Fresh") + let backup = url.appendingPathExtension("bak") + #expect(try Data(contentsOf: backup) == Data("not json".utf8)) + #expect(CameraRawPresetStore(url: url).presets.map(\.name) == ["Fresh"]) + } + + private func session(red: CGFloat = 160 / 255, green: CGFloat = 140 / 255, blue: CGFloat = 120 / 255) throws -> EditorSession { + let context = try BrushRaster.context(width: 8, height: 8, mask: false) + context.setFillColor(CGColor(srgbRed: red, green: green, blue: blue, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + let image = try #require(context.makeImage()) + let session = EditorSession() + session.createDocument(width: 8, height: 8) + session.insert(ImportedImage(image: image, thumbnail: image, name: "Warm")) + return session + } + + @Test func applyingAPresetSetsTheSlidersAndKeepsThePanelOpen() async throws { + let session = try session() + session.beginFilter(.cameraRaw) + let edit = try #require(session.filterEdit) + var drawn = edit.settings + drawn.cameraRaw.geometry.upright = .guided + drawn.cameraRaw.geometry.guides = [CameraRawGeometryGuide(startX: 0.2, startY: 0.1, endX: 0.2, endY: 0.9)] + session.updateFilter(drawn, preview: true) + #expect(session.cameraRawPresetSettings?.geometry.guides.isEmpty == true, "what Save would keep leaves the lines out") + await session.applyCameraRawPreset(CameraRawPreset(name: "Look", settings: edited())) + #expect(session.filterEdit === edit) + #expect(edit.settings.cameraRaw == edited(), "every slider, and the drawn lines go with the old look") + edit.committing = true + await session.applyCameraRawPreset(CameraRawPreset(name: "Plain", settings: CameraRawSettings())) + #expect(edit.settings.cameraRaw == edited(), "ignored once OK is pressed") + } + + @Test func presetsApplyOnlyToCameraRaw() async throws { + let session = try session() + session.beginFilter(.gaussianBlur) + let before = try #require(session.filterEdit).settings + #expect(session.cameraRawPresetSettings == nil) + await session.applyCameraRawPreset(CameraRawPreset(name: "Look", settings: edited())) + #expect(session.filterEdit?.settings == before) + } + + /// A preset saved with White Balance > Auto balances the image it's applied to, not the one it was saved on. + @Test func anAutoWhiteBalancePresetRunsAutoAgain() async throws { + let session = try session() + session.beginFilter(.cameraRaw) + var look = CameraRawSettings() + look.whiteBalance = .auto + look.temperature = 50 // warmer still: the opposite of what this image needs + look.contrast = 20 + await session.applyCameraRawPreset(CameraRawPreset(name: "Auto", settings: look)) + let applied = try #require(session.filterEdit).settings.cameraRaw + #expect(applied.whiteBalance == .auto && applied.contrast == 20) + #expect(applied.temperature < 0, "this warm image's own balance, not the saved one: \(applied.temperature)") + } + + @Test func savingAsksBeforeReplacing() throws { + let store = CameraRawPresetStore(url: try storeURL()) + try store.save(CameraRawSettings(), as: "Film") + #expect(CameraRawPresetMenu.saveStep(for: " ", in: store) == .invalidName) + #expect(CameraRawPresetMenu.saveStep(for: " Matte ", in: store) == .save("Matte")) + #expect(CameraRawPresetMenu.saveStep(for: "FILM", in: store) == .confirmReplace(existing: "Film", name: "FILM")) + } + + @Test func menuFitsThePanel() throws { + let session = try session() + session.beginFilter(.cameraRaw) + let store = CameraRawPresetStore(url: try storeURL()) + let empty = NSHostingView(rootView: CameraRawPresetMenu(session: session, store: store)) + #expect(empty.fittingSize.width > 0 && empty.fittingSize.width < FloatingPanelController.dockedWidth) + try store.save(CameraRawSettings(), as: String(repeating: "Long name ", count: 6)) + let full = NSHostingView(rootView: CameraRawPresetMenu(session: session, store: store)) + #expect(full.fittingSize.width < FloatingPanelController.dockedWidth, "a long name stays in the menu, not the button") + } +} diff --git a/README.md b/README.md index 9540063cf..42441a39c 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ brew install --cask robbietilton-compositor - Eyedropper and a full color picker ### Adjustments and filters -- Camera Raw filter: light, color, curves, color mixer, color grading, detail, optics and geometry, in a panel beside the canvas +- Camera Raw filter: light, color, curves, color mixer, color grading, detail, optics and geometry, in a panel beside the canvas; save its settings as named presets - Levels (with Auto), Curves, Hue/Saturation, Exposure, Gradient Map, Grain, Black & White, Color Balance and Invert - Gaussian Blur and Motion Blur that spread past a layer's edges - Add Noise, Vignette, Bloom / Glow, Tonal Contrast, Lens Correction and Remove Background