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