diff --git a/Makefile b/Makefile index dbdd700..427dc02 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ check-built-core: python3 -B Tests/contained_crop_test.py python3 -B Tests/catalog_freshness_probe_test.py python3 -B Tests/variant_modified_probe_test.py + python3 -B Tests/native_bounds_probe_test.py python3 -B Tests/stored_read_test.py python3 -B Tests/benchmark_reads_test.py python3 -B scripts/generate-native-editing.py --check diff --git a/Sources/CaptureOneCore/NativeEditing.swift b/Sources/CaptureOneCore/NativeEditing.swift index f66efae..ae80f92 100644 --- a/Sources/CaptureOneCore/NativeEditing.swift +++ b/Sources/CaptureOneCore/NativeEditing.swift @@ -55,6 +55,8 @@ public struct NativeField: Codable { public let writable: Bool public let values: [String] public let route: String + public let minimum: Double? + public let maximum: Double? } public struct NativeLayer: Codable, Equatable { public let nativeName: String @@ -158,16 +160,13 @@ public enum NativeEditing { } } guard valid else { throw C1Error.invalidRequest("Invalid value for native \(target.scope).\(name) (\(field.type)).") } - // Use demonstrated bounds where retained. Unspecified native ranges are checked by readback. - if case .number(let n) = value { - var ranges: [String: ClosedRange] = ["opacity": 1...100, "distortion": 0...100] - for field in FieldRegistry.shared.supportedAdjustmentFields { - if let low = field.minValue, let high = field.maxValue { ranges[field.name] = low...high } - } - if let range = ranges[name], !range.contains(n) { throw C1Error.invalidRequest("Native \(name) outside \(range).") } + // Capture One's own range errors misreport some limits, so measured bounds are checked first. + if case .number(let n) = value, let low = field.minimum, let high = field.maximum, !(low...high).contains(n) { + throw C1Error.invalidRequest("Native \(name) must be within \(format(low))...\(format(high)).") } } } + private static func format(_ n: Double) -> String { n.rounded() == n ? String(Int(n)) : String(n) } static func hash(_ value: T) throws -> String { let e = JSONEncoder(); e.outputFormatting = [.sortedKeys] return SHA256.hash(data: try e.encode(value)).map { String(format: "%02x", $0) }.joined() @@ -293,8 +292,9 @@ extension NativeEditing { for field in fields.values.flatMap({ $0 }) where field.writable { var schema: [String:Any] switch field.type { - case "real": schema = ["type":"number"] - case "integer": schema = ["type":"integer"] + case "real", "integer": + schema = ["type": field.type == "real" ? "number" : "integer"] + if let low = field.minimum, let high = field.maximum { schema["minimum"] = low; schema["maximum"] = high } case "boolean": schema = ["type":"boolean"] case "curve": schema = ["type":"array", "items":["type":"number", "minimum":0, "maximum":100], "minItems":4, "maxItems":128, "description":"Flat x,y point pairs, ordered by strictly increasing x, in 0...100."] case "RGB color": schema = ["type":"array", "items":["type":"integer", "minimum":0, "maximum":65535], "minItems":3, "maxItems":3] diff --git a/Sources/CaptureOneCore/Resources/NativeEditing.json b/Sources/CaptureOneCore/Resources/NativeEditing.json index 38fa252..ba2a466 100644 --- a/Sources/CaptureOneCore/Resources/NativeEditing.json +++ b/Sources/CaptureOneCore/Resources/NativeEditing.json @@ -86,119 +86,153 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 800, + "maximum": 14000 }, { "name": "tint", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -50, + "maximum": 50 }, { "name": "exposure", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -4, + "maximum": 4 }, { "name": "brightness", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -50, + "maximum": 50 }, { "name": "contrast", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -50, + "maximum": 50 }, { "name": "saturation", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "color balance master hue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "color balance master saturation", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 1 }, { "name": "color balance shadow hue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "color balance shadow saturation", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 1 }, { "name": "color balance shadow lightness", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "color balance midtone hue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "color balance midtone saturation", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 1 }, { "name": "color balance midtone lightness", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "color balance highlight hue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "color balance highlight saturation", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 1 }, { "name": "color balance highlight lightness", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "black and white", @@ -212,210 +246,270 @@ "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white yellow sensitivity", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white green sensitivity", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white cyan sensitivity", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white blue sensitivity", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white magenta sensitivity", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black and white split highlight hue", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "black and white split highlight saturation", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "black and white split shadow hue", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 360 }, { "name": "black and white split shadow saturation", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "level highlight rgb", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level shadow rgb", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level highlight red", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level shadow red", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level highlight green", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level shadow green", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level highlight blue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level shadow blue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target highlight rgb", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target shadow rgb", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target highlight red", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target shadow red", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target highlight green", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target shadow green", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target highlight blue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level target shadow blue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 255 }, { "name": "level midtone rgb", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "level midtone red", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "level midtone green", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "level midtone blue", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -1, + "maximum": 1 }, { "name": "rgb curve", @@ -457,35 +551,45 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "highlight adjustment", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "shadow recovery", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "white recovery", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "black recovery", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "clarity method", @@ -504,21 +608,27 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "clarity structure", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "dehaze amount", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -100, + "maximum": 100 }, { "name": "dehaze color", @@ -532,7 +642,9 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": -4, + "maximum": 4 }, { "name": "vignetting method", @@ -550,56 +662,72 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 1000 }, { "name": "sharpening radius", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0.2, + "maximum": 2.5 }, { "name": "sharpening threshold", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 12 }, { "name": "sharpening halo suppression", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "noise reduction luminance", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "noise reduction details", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "noise reduction color", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "noise reduction single pixel", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "film grain type", @@ -620,28 +748,36 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "film grain granularity", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "moire amount", "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 }, { "name": "moire pattern", "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 4, + "maximum": 80 } ], "lens": [ @@ -685,7 +821,9 @@ "type": "real", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 120 }, { "name": "sharpness falloff", @@ -793,7 +931,9 @@ "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 0, + "maximum": 100 } ], "luma": [ diff --git a/Tests/CaptureOneCoreTests/NativeEditingTests.swift b/Tests/CaptureOneCoreTests/NativeEditingTests.swift index 695f0b4..d05df65 100644 --- a/Tests/CaptureOneCoreTests/NativeEditingTests.swift +++ b/Tests/CaptureOneCoreTests/NativeEditingTests.swift @@ -124,6 +124,69 @@ struct NativeEditingTests { } peopleRefusals() peopleSuccess() + bounds() + outOfRangeSetDispatchesNothing() + } + + static func invalidRequestMessage(_ block: () throws -> Void) -> String? { + do { try block() } catch C1Error.invalidRequest(let message) { return message } catch { return "unexpected \(error)" } + return nil + } + + static func bounds() { + let layer = NativeTarget(scope:"layer", layer:1), lens = NativeTarget(scope:"lens"), image = NativeTarget() + for (target, name, low, high, below, above, range) in [ + (image, "exposure", -4.0, 4.0, -4.01, 4.01, "-4...4"), + (image, "sharpening radius", 0.2, 2.5, 0.19, 2.51, "0.2...2.5"), + (image, "moire pattern", 4, 80, 3, 81, "4...80"), + (image, "level highlight rgb", 0, 255, -1, 256, "0...255"), + (image, "color balance master hue", 0, 360, -1, 361, "0...360"), + (layer, "opacity", 0, 100, -1, 101, "0...100"), + (lens, "distortion", 0, 120, -1, 120.01, "0...120"), + ] { + for accepted in [low, high] { + XCTAssertNil(invalidRequestMessage { try NativeEditing.validate([name:.number(accepted)], target:target) }, "\(name) \(accepted) is within bounds") + } + for rejected in [below, above] { + XCTAssertEqual(invalidRequestMessage { try NativeEditing.validate([name:.number(rejected)], target:target) }, "Native \(name) must be within \(range).") + } + } + let native = Dictionary(uniqueKeysWithValues: NativeEditing.fields["adjustments"]!.map { ($0.name, $0) }) + for spec in FieldRegistry.shared.supportedAdjustmentFields where spec.minValue != nil || spec.maxValue != nil { + XCTAssertEqual(native[spec.name]?.minimum, spec.minValue, "\(spec.name) minimum agrees with the tonal registry") + XCTAssertEqual(native[spec.name]?.maximum, spec.maxValue, "\(spec.name) maximum agrees with the tonal registry") + } + let properties = NativeEditing.patchSchema["properties"] as! [String:[String:Any]] + let bounded = NativeEditing.fields.values.flatMap { $0 }.filter { $0.minimum != nil } + XCTAssertEqual(bounded.count, 70) + for field in bounded { + XCTAssertEqual(properties[field.name]?["minimum"] as? Double, field.minimum, "\(field.name) schema minimum") + XCTAssertEqual(properties[field.name]?["maximum"] as? Double, field.maximum, "\(field.name) schema maximum") + } + // Capture One clamps an over-range hue to 360 without an error, so the readback must catch it. + XCTAssertFalse(NativeEditing.matchesReadback(field:"color balance master hue", expected:.number(361), actual:.number(360))) + // Tint is stored as float32. + XCTAssertTrue(NativeEditing.matchesReadback(field:"tint", expected:.number(5.297744), actual:.number(5.297754))) + } + + static func outOfRangeSetDispatchesNothing() { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try! FileManager.default.createDirectory(at:directory, withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:directory) } + let fake = FakeScript(directory:directory), bridge = NativeFake(fake) + let core = SessionController(lock: testLock, executor:bridge, appInstance:{ fake.generation }, databaseIdentity:{ _ in "db" }) + let journal = OperationJournal(sessionDirectory:directory) + XCTAssertNoThrowBlock { + let doc = try core.getDocumentInfo(), source = try core.get(ref:"1") + let ref = try core.editVariant(sourceRef:"1", ifState:source.stateHash, ifDocument:doc.openToken).workingRef + let before = try core.get(ref:ref, nativeTargets:[NativeTarget()]).nativeSnapshots![0] + let entries = journal.loadEntries().count + bridge.calls.removeAll() + let message = invalidRequestMessage { _ = try core.nativeSet(workingRef:ref, target:NativeTarget(), ifNativeState:before.nativeStateHash, patch:["level highlight rgb":.number(256)]) } + XCTAssertEqual(message, "Native level highlight rgb must be within 0...255.") + XCTAssertEqual(bridge.calls, [], "Nothing is read or dispatched") + XCTAssertEqual(journal.loadEntries().count, entries, "Nothing is journaled") + } } /// A successful people mask keeps existing layers and appends one mask layer, diff --git a/Tests/CaptureOneCoreTests/RecipeTests.swift b/Tests/CaptureOneCoreTests/RecipeTests.swift index a0ab863..e0d4ece 100644 --- a/Tests/CaptureOneCoreTests/RecipeTests.swift +++ b/Tests/CaptureOneCoreTests/RecipeTests.swift @@ -18,7 +18,9 @@ private final class RecipeFake: ScriptExecuting { init(_ directory: URL) { geometry = GeometryFake(directory:directory) } var fields: [String] { ["exposure","temperature","tint"] + Recipes.supportedFields } func values(_ id: String) -> [String:Any] { - var v = Dictionary(uniqueKeysWithValues:fields.map { ($0,properties[$0] ?? 0) }) + // A captured recipe replays every value, and 0 is out of range for sharpening radius and moire pattern. + let floors = Dictionary(uniqueKeysWithValues:NativeEditing.fields["adjustments"]!.compactMap { f in f.minimum.map { (f.name, max(0, $0)) } }) + var v = Dictionary(uniqueKeysWithValues:fields.map { ($0, properties[$0] ?? floors[$0] ?? 0) }) for key in Recipes.curveFields { v[key] = properties[key] ?? [0.0,0,100,100] } v["film grain type"] = properties["film grain type"] ?? "fine" v["vignetting method"] = properties["vignetting method"] ?? "elliptic on crop" diff --git a/Tests/native_bounds_probe_test.py b/Tests/native_bounds_probe_test.py new file mode 100644 index 0000000..2ec0ee5 --- /dev/null +++ b/Tests/native_bounds_probe_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Check the native bounds probe's classification without Capture One.""" +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('native_bounds_probe', ROOT / 'scripts/probe-native-bounds.py') +probe = importlib.util.module_from_spec(spec); spec.loader.exec_module(probe) + + +def write(written, read_back, error=None): + return dict(written=written, readBack=read_back, error=error) + + +class ClassifyTests(unittest.TestCase): + def test_rejected_past_value_confirms(self): + self.assertEqual(probe.classify('exposure', write(4, 4.0), write(4.01, 4.0, 'Cannot set')), 'confirmed') + + def test_accepted_past_value_is_a_mismatch(self): + self.assertEqual(probe.classify('brightness', write(50, 50.0), write(51, 51.0)), 'mismatch') + + def test_error_with_the_past_value_applied_is_a_mismatch(self): + self.assertEqual(probe.classify('brightness', write(50, 50.0), write(51, 51.0, 'Cannot set')), 'mismatch') + + def test_rejected_bound_is_a_mismatch(self): + self.assertEqual(probe.classify('level highlight rgb', write(255, 250.0, 'Cannot set'), write(256, 250.0, 'Cannot set')), 'mismatch') + self.assertEqual(probe.classify('level highlight rgb', write(255, 250.0), write(256, 250.0, 'Cannot set')), 'mismatch') + + def test_silent_hue_clamp_confirms_clamped(self): + self.assertEqual(probe.classify('color balance master hue', write(360, 360.0), write(361, 360.0)), 'confirmed-clamped') + + def test_hue_is_circular(self): + self.assertEqual(probe.classify('color balance master hue', write(0, 360.0), write(-1, 359.0)), 'mismatch') + + def test_tolerances(self): + self.assertEqual(probe.classify('tint', write(50, 49.995), write(51, 49.995, 'x')), 'confirmed') + self.assertEqual(probe.classify('exposure', write(4, 3.999), write(4.01, 4.0, 'x')), 'mismatch') + self.assertEqual(probe.classify('temperature', write(14000, 14000.05), write(14001, 14000.0, 'x')), 'confirmed') + + def test_missing_read_back(self): + self.assertEqual(probe.classify('exposure', write(4, None), write(4.01, None, 'x')), 'mismatch') + + def test_step_and_partner(self): + self.assertEqual(probe.step('real', .2, 2.5), .01) + self.assertEqual(probe.step('real', 0, 100), 1) + self.assertEqual(probe.step('integer', 4, 10), 1) + self.assertEqual(probe.partner('level target highlight red'), 'level target shadow red') + self.assertEqual(probe.partner('level shadow rgb'), 'level highlight rgb') + self.assertIsNone(probe.partner('level midtone rgb')) + self.assertIsNone(probe.partner('black and white split shadow hue')) + + def test_literal(self): + self.assertEqual([probe.literal(v) for v in (-101, 2.51, 14000.0, 0.19)], ['-101', '2.51', '14000', '0.19']) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/reference/native-editing.md b/docs/reference/native-editing.md index 0e110ff..7c703fe 100644 --- a/docs/reference/native-editing.md +++ b/docs/reference/native-editing.md @@ -114,9 +114,37 @@ Property keys use the exact dictionary names, including spaces. Booleans are JSON booleans; native enumerations are the strings listed by `capabilities`. Curves are flat `x,y` pairs in the native 0–100 scale, with strictly increasing x coordinates. RGB colors are three integers in 0–65535. Values must have the -right native type; documented/retained ranges are enforced where available. -For properties without qualified bounds, native normalization can produce a -readback mismatch; this is an uncertain operation requiring normal recovery. +right native type. + +### Numeric bounds + +The fields below have bounds measured on Capture One 16.8.5.30. A value outside +its range fails with `invalidRequest` before dispatch, with no journal entry, +for example `Native exposure must be within -4...4.` Bounds are inclusive and +apply to every target of the scope, including layer adjustments. The `schema` +and `capabilities.nativeEditing` report each one as `minimum` and `maximum`. + +| Range | Fields (`adjustments` scope unless named) | +|---|---| +| -100...100 | `black and white blue sensitivity`, `black and white cyan sensitivity`, `black and white green sensitivity`, `black and white magenta sensitivity`, `black and white red sensitivity`, `black and white yellow sensitivity`, `black recovery`, `clarity amount`, `clarity structure`, `dehaze amount`, `highlight adjustment`, `highlight recovery`, `saturation`, `shadow recovery`, `white recovery` | +| -50...50 | `brightness`, `contrast`, `tint` | +| -4...4 | `exposure`, `vignetting amount` | +| -1...1 | `color balance highlight lightness`, `color balance midtone lightness`, `color balance shadow lightness`, `level midtone blue`, `level midtone green`, `level midtone red`, `level midtone rgb` | +| 0...1 | `color balance highlight saturation`, `color balance master saturation`, `color balance midtone saturation`, `color balance shadow saturation` | +| 0...12 | `sharpening threshold` | +| 0...100 | `black and white split highlight saturation`, `black and white split shadow saturation`, `film grain granularity`, `film grain impact`, `moire amount`, `noise reduction color`, `noise reduction details`, `noise reduction luminance`, `noise reduction single pixel`, `opacity` (layer scope), `sharpening halo suppression` | +| 0...120 | `distortion` (lens scope) | +| 0...255 | `level highlight blue`, `level highlight green`, `level highlight red`, `level highlight rgb`, `level shadow blue`, `level shadow green`, `level shadow red`, `level shadow rgb`, `level target highlight blue`, `level target highlight green`, `level target highlight red`, `level target highlight rgb`, `level target shadow blue`, `level target shadow green`, `level target shadow red`, `level target shadow rgb` | +| 0...360 | `black and white split highlight hue`, `black and white split shadow hue`, `color balance highlight hue`, `color balance master hue`, `color balance midtone hue`, `color balance shadow hue` | +| 0...1000 | `sharpening amount` | +| 0.2...2.5 | `sharpening radius` | +| 4...80 | `moire pattern` | +| 800...14000 | `temperature` | + +Capture One's own range errors can name the wrong limit, and it clamps an over-range color balance hue to 360 without an error, so these +checks run first. For numeric fields without bounds, native normalization can +produce a readback mismatch; this is an uncertain operation requiring normal +recovery. Reads distinguish native unset values (`null`) from per-property `unavailable` errors. Unset layer properties may be assigned typed values; `null` is not accepted as a write or treated as zero. Unavailable properties cannot be written. Application/permission/timeout errors diff --git a/scripts/generate-native-editing.py b/scripts/generate-native-editing.py index e85da55..2556017 100644 --- a/scripts/generate-native-editing.py +++ b/scripts/generate-native-editing.py @@ -7,7 +7,8 @@ from pathlib import Path import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[1] -root = ET.parse(ROOT / 'sdef/16.8.5.30.sdef').getroot() +BUILD = '16.8.5.30' +root = ET.parse(ROOT / f'sdef/{BUILD}.sdef').getroot() classes = {c.attrib['name']: c for c in root.iter('class')} enums = {e.attrib['name']: [v.attrib['name'] for v in e.findall('enumerator')] for e in root.iter('enumeration')} scopes = {'adjustments': 'adjustment settings', 'lens': 'lens correction settings', 'layer': 'layer', 'luma': 'luma range settings', 'basicColor': 'basic color correction', 'advancedColor': 'advanced color correction'} @@ -22,6 +23,15 @@ if typ not in ('real', 'integer', 'boolean', 'text', 'curve', 'RGB color') and typ not in enums: raise ValueError((scope, name, typ)) fields.append(dict(name=name, type=typ, writable=p.attrib.get('access','rw') != 'r' and name not in geometry, values=enums.get(typ, []), route='geometry_set' if name in geometry else 'native_set')) manifest[scope] = fields +bounds_table = json.loads((ROOT / f'sdef/{BUILD}.native-bounds.json').read_text()) +if bounds_table['build'] != BUILD: raise SystemExit('Native bounds build differs from the sdef build: ' + bounds_table['build']) +for scope, ranges in bounds_table['bounds'].items(): + for name, (low, high) in ranges.items(): + field = next((f for f in manifest.get(scope, []) if f['name'] == name), None) + if field is None or not field['writable'] or field['type'] not in ('real', 'integer'): raise SystemExit(f'Native bound names no writable real or integer field: {scope}.{name}') + if field['type'] == 'integer' and not (float(low).is_integer() and float(high).is_integer()): raise SystemExit(f'Integer native bound is not integral: {scope}.{name}') + if not low < high: raise SystemExit(f'Native bound is empty: {scope}.{name}') + field['minimum'], field['maximum'] = low, high # Base-characteristics and LCC switches belong to the variant rather than adjustments. manifest['variant'] = [dict(name=p.attrib['name'],type=p.attrib['type'],writable=True,values=enums.get(p.attrib['type'],[]),route='native_set') for p in classes['variant'].findall('property') if p.attrib['name'] in ('processing mode','LCC color cast','LCC dust removal','LCC uniform light','LCC uniform light amount')] out = ROOT / 'Sources/CaptureOneCore/Resources' diff --git a/scripts/probe-native-bounds.py b/scripts/probe-native-bounds.py new file mode 100755 index 0000000..397299e --- /dev/null +++ b/scripts/probe-native-bounds.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Confirm the native bounds table on an owned disposable Session: write each bound and just past it. + +c1 rejects out-of-range values before dispatch, so the writes go through direct AppleScript on the +fixture's variant, each guarded by the document-id check. c1 only opens the fixture and supplies its +identity. Requires zero open documents (C1_TEST_RAW_FIXTURE names the RAW to copy). Never quits +Capture One; closes only its own document, and keeps the fixture and evidence on failure. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'Tests')) +from catalog_integration_test import CLI, apple, sha +from editing_fixture import EditingFixture + +TABLE = ROOT / 'sdef/16.8.5.30.native-bounds.json' +REGISTRY = ROOT / 'Sources/CaptureOneCore/Resources/NativeEditing.json' +LAYER_NAME = 'c1 bounds probe' +CONTEXT = {'adjustments': 'adjustments of v', + 'layer': f'(first layer of v whose name is {json.dumps(LAYER_NAME)})', + 'lens': 'lens correction of v'} +TOLERANCE = {'tint': .01, 'temperature': .1} +PARTNER = {'highlight': 'shadow', 'shadow': 'highlight'} + + +def step(kind, low, high): + return 1 if kind == 'integer' or high - low >= 20 else .01 + + +def close(field, actual, expected): + if actual is None: + return False + distance = abs(actual - expected) + if field.endswith(' hue'): + distance = min(distance % 360, 360 - distance % 360) + return distance <= TOLERANCE.get(field, 1e-4) + + +def classify(field, at, past): + """A bound holds when writing it reads it back and writing just past it does not.""" + if at['error'] is not None or not close(field, at['readBack'], at['written']): + return 'mismatch' + if close(field, past['readBack'], past['written']): + return 'mismatch' + return 'confirmed' if past['error'] is not None else 'confirmed-clamped' + + +def partner(field): + """The level field whose value can constrain this one: 'level shadow rgb' for 'level highlight rgb'.""" + words = field.split() + for i, word in enumerate(words): + if words[0] == 'level' and word in PARTNER: + return ' '.join(words[:i] + [PARTNER[word]] + words[i + 1:]) + return None + + +def literal(value): + value = float(value) + return str(int(value)) if value.is_integer() else repr(round(value, 6)) + + +def number(text): + return None if text == 'missing value' else float(text) + + +class Variant: + """Direct AppleScript on one variant of the expected open document.""" + + def __init__(self, document_id, variant_id): + self.guard = (f'if (count of documents) is not 1 then error "Document changed"\n' + f'set d to current document\n' + f'if (id of d as text) is not {json.dumps(document_id)} then error "Document changed"\n' + f'set v to variant id {json.dumps(variant_id)} of d\n') + + def run(self, line): + try: + return apple(self.guard + line), None + except AssertionError as error: + text = str(error).strip() + if 'Document changed' in text: + raise + return None, text + + def read(self, scope, field): + out, error = self.run(f'return {field} of {CONTEXT[scope]}') + assert error is None, (scope, field, error) + return number(out) + + def write(self, scope, field, value): + _, error = self.run(f'set {field} of {CONTEXT[scope]} to {literal(value)}') + return dict(written=value, readBack=self.read(scope, field), error=error) + + +def probe(variant, scope, field, kind, low, high): + s = step(kind, low, high) + original = variant.read(scope, field) + other = partner(field) + writes = dict(min=variant.write(scope, field, low), max=variant.write(scope, field, high), + belowMin=variant.write(scope, field, round(low - s, 6)), + aboveMax=variant.write(scope, field, round(high + s, 6))) + restore = variant.write(scope, field, original) + return dict(scope=scope, field=field, kind=kind, bounds=[low, high], step=s, original=original, + partner=other and dict(field=other, value=variant.read(scope, other)), + writes=writes, restore=restore, restored=close(field, restore['readBack'], original), + minBound=classify(field, writes['min'], writes['belowMin']), + maxBound=classify(field, writes['max'], writes['aboveMax'])) + + +def render(rows): + width = max(len(r['field']) for r in rows) + lines = [f'{"field".ljust(width)} {"bounds":<16} {"min":<18} {"max":<18} past min / past max'] + for r in rows: + past = ' / '.join(f"{w['written']}->{w['readBack']}{' err' if w['error'] else ''}" + for w in (r['writes']['belowMin'], r['writes']['aboveMax'])) + lines.append(f'{r["field"].ljust(width)} {str(r["bounds"]):<16} {r["minBound"]:<18} {r["maxBound"]:<18} {past}') + return '\n'.join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--evidence', type=Path, help='evidence directory (default: /evidence)') + parser.add_argument('--table', type=Path, default=TABLE) + args = parser.parse_args() + table = json.loads(args.table.read_text()) + kinds = {(scope, f['name']): f['type'] for scope, fields in json.loads(REGISTRY.read_text()).items() for f in fields} + raw = Path(os.environ['C1_TEST_RAW_FIXTURE']).resolve() + original = sha(raw) + assert apple('return count of documents') == '0', 'Close documents before probing' + assert json.loads(apple('return version')) == table['build'], 'Table build differs from the running Capture One' + record = dict(build=table['build'], table=str(args.table), source=table['source'], events=[], fields=[]) + def log(name, value): + record['events'].append(dict(event=name, value=value)) + owned = EditingFixture('bounds', raw, log) + evidence = (args.evidence or owned.base / 'evidence').resolve() + evidence.mkdir(parents=True, exist_ok=True) + path = evidence / 'native-bounds.json' + def save(): + path.write_text(json.dumps(record, indent=2) + '\n') + def cli(*argv): + r = subprocess.run([str(CLI), *map(str, argv), '--format', 'json'], env=owned.environment, + capture_output=True, text=True, timeout=180) + assert r.returncode == 0, r.stderr + return json.loads(r.stdout) + try: + variants = owned.open(cli) + doc = cli('doc', 'info') + variant = Variant(doc['documentId'], variants[0]['id']) + variant.run(f'make new layer at v with properties {{name:{json.dumps(LAYER_NAME)}, kind:adjustment}}') + assert variant.run(f'return count of (layers of v whose name is {json.dumps(LAYER_NAME)})') == ('1', None) + for scope, fields in table['bounds'].items(): + for field, (low, high) in fields.items(): + row = probe(variant, scope, field, kinds[(scope, field)], low, high) + record['fields'].append(row) + save() + print(f"{field}: {row['minBound']} / {row['maxBound']}", flush=True) + assert row['restored'], (field, row['original'], row['restore']) + assert sha(raw) == sha(owned.fixture) == original, 'RAW changed' + owned.close() + except BaseException: + owned.retain_and_close() + save() + raise + rows = record['fields'] + mismatches = [r['field'] for r in rows if 'mismatch' in (r['minBound'], r['maxBound'])] + record['summary'] = dict(fields=len(rows), mismatches=mismatches, rawSHA256=original, fixture=str(owned.base)) + save() + print(render(rows)) + print(f'{len(mismatches)} mismatched of {len(rows)}; evidence {path}') + return 1 if mismatches else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/sdef/16.8.5.30.native-bounds.json b/sdef/16.8.5.30.native-bounds.json new file mode 100644 index 0000000..b787f51 --- /dev/null +++ b/sdef/16.8.5.30.native-bounds.json @@ -0,0 +1,292 @@ +{ + "build": "16.8.5.30", + "source": "Adjustment bounds bisected by byjustinjones/capture-one-mcp at cfb4e42, docs/adjustment-round-trip.json (MIT), rounded to the intended limits; layer opacity and lens distortion measured live. All confirmed at each bound and just past it by scripts/probe-native-bounds.py.", + "bounds": { + "adjustments": { + "black and white blue sensitivity": [ + -100, + 100 + ], + "black and white cyan sensitivity": [ + -100, + 100 + ], + "black and white green sensitivity": [ + -100, + 100 + ], + "black and white magenta sensitivity": [ + -100, + 100 + ], + "black and white red sensitivity": [ + -100, + 100 + ], + "black and white split highlight hue": [ + 0, + 360 + ], + "black and white split highlight saturation": [ + 0, + 100 + ], + "black and white split shadow hue": [ + 0, + 360 + ], + "black and white split shadow saturation": [ + 0, + 100 + ], + "black and white yellow sensitivity": [ + -100, + 100 + ], + "black recovery": [ + -100, + 100 + ], + "brightness": [ + -50, + 50 + ], + "clarity amount": [ + -100, + 100 + ], + "clarity structure": [ + -100, + 100 + ], + "color balance highlight hue": [ + 0, + 360 + ], + "color balance highlight lightness": [ + -1, + 1 + ], + "color balance highlight saturation": [ + 0, + 1 + ], + "color balance master hue": [ + 0, + 360 + ], + "color balance master saturation": [ + 0, + 1 + ], + "color balance midtone hue": [ + 0, + 360 + ], + "color balance midtone lightness": [ + -1, + 1 + ], + "color balance midtone saturation": [ + 0, + 1 + ], + "color balance shadow hue": [ + 0, + 360 + ], + "color balance shadow lightness": [ + -1, + 1 + ], + "color balance shadow saturation": [ + 0, + 1 + ], + "contrast": [ + -50, + 50 + ], + "dehaze amount": [ + -100, + 100 + ], + "exposure": [ + -4, + 4 + ], + "film grain granularity": [ + 0, + 100 + ], + "film grain impact": [ + 0, + 100 + ], + "highlight adjustment": [ + -100, + 100 + ], + "highlight recovery": [ + -100, + 100 + ], + "level highlight blue": [ + 0, + 255 + ], + "level highlight green": [ + 0, + 255 + ], + "level highlight red": [ + 0, + 255 + ], + "level highlight rgb": [ + 0, + 255 + ], + "level midtone blue": [ + -1, + 1 + ], + "level midtone green": [ + -1, + 1 + ], + "level midtone red": [ + -1, + 1 + ], + "level midtone rgb": [ + -1, + 1 + ], + "level shadow blue": [ + 0, + 255 + ], + "level shadow green": [ + 0, + 255 + ], + "level shadow red": [ + 0, + 255 + ], + "level shadow rgb": [ + 0, + 255 + ], + "level target highlight blue": [ + 0, + 255 + ], + "level target highlight green": [ + 0, + 255 + ], + "level target highlight red": [ + 0, + 255 + ], + "level target highlight rgb": [ + 0, + 255 + ], + "level target shadow blue": [ + 0, + 255 + ], + "level target shadow green": [ + 0, + 255 + ], + "level target shadow red": [ + 0, + 255 + ], + "level target shadow rgb": [ + 0, + 255 + ], + "moire amount": [ + 0, + 100 + ], + "moire pattern": [ + 4, + 80 + ], + "noise reduction color": [ + 0, + 100 + ], + "noise reduction details": [ + 0, + 100 + ], + "noise reduction luminance": [ + 0, + 100 + ], + "noise reduction single pixel": [ + 0, + 100 + ], + "saturation": [ + -100, + 100 + ], + "shadow recovery": [ + -100, + 100 + ], + "sharpening amount": [ + 0, + 1000 + ], + "sharpening halo suppression": [ + 0, + 100 + ], + "sharpening radius": [ + 0.2, + 2.5 + ], + "sharpening threshold": [ + 0, + 12 + ], + "temperature": [ + 800, + 14000 + ], + "tint": [ + -50, + 50 + ], + "vignetting amount": [ + -4, + 4 + ], + "white recovery": [ + -100, + 100 + ] + }, + "layer": { + "opacity": [ + 0, + 100 + ] + }, + "lens": { + "distortion": [ + 0, + 120 + ] + } + } +}