From 55ac186eac660dde322fb0a33a380e243cacad6d Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:29:14 -0400 Subject: [PATCH 1/7] Record measured native adjustment bounds Co-Authored-By: Claude Opus 5.5 --- sdef/16.8.5.30.native-bounds.json | 292 ++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 sdef/16.8.5.30.native-bounds.json 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..20375de --- /dev/null +++ b/sdef/16.8.5.30.native-bounds.json @@ -0,0 +1,292 @@ +{ + "build": "16.8.5.30", + "source": "Bisected by byjustinjones/capture-one-mcp at cfb4e42, docs/adjustment-round-trip.json (MIT), rounded to the intended limits; 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": [ + 1, + 100 + ] + }, + "lens": { + "distortion": [ + 0, + 100 + ] + } + } +} From 00dcaad58cb035d8ed6e66c30024d05a368932a7 Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:30:57 -0400 Subject: [PATCH 2/7] Generate native field bounds from the measured table The generator fails when the table's build differs from the sdef build, names a field that is not a writable real or integer field in its scope, or gives an integer field non-integral bounds. Co-Authored-By: Claude Opus 5.5 --- .../Resources/NativeEditing.json | 280 +++++++++++++----- scripts/generate-native-editing.py | 12 +- 2 files changed, 221 insertions(+), 71 deletions(-) diff --git a/Sources/CaptureOneCore/Resources/NativeEditing.json b/Sources/CaptureOneCore/Resources/NativeEditing.json index 38fa252..2d6bbb7 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": 100 }, { "name": "sharpness falloff", @@ -793,7 +931,9 @@ "type": "integer", "writable": true, "values": [], - "route": "native_set" + "route": "native_set", + "minimum": 1, + "maximum": 100 } ], "luma": [ 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' From c322902fb5c6b0eddc9733b7a52ed0fad9ddf7f1 Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:42:09 -0400 Subject: [PATCH 3/7] Check native numeric fields against their measured bounds native_set rejects a value outside a field's measured range with invalidRequest before any read, dispatch or journal entry, and the patch schema publishes the same minimum and maximum. This replaces the hard-coded opacity and distortion map and the tonal registry loop. Co-Authored-By: Claude Opus 5.5 --- Sources/CaptureOneCore/NativeEditing.swift | 18 +++--- .../NativeEditingTests.swift | 63 +++++++++++++++++++ Tests/CaptureOneCoreTests/RecipeTests.swift | 4 +- 3 files changed, 75 insertions(+), 10 deletions(-) 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/Tests/CaptureOneCoreTests/NativeEditingTests.swift b/Tests/CaptureOneCoreTests/NativeEditingTests.swift index 695f0b4..eccb77b 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", 1, 100, 0, 101, "1...100"), + (lens, "distortion", 0, 100, -1, 101, "0...100"), + ] { + 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" From c970d66078c7ab0e669dbedaef6ef2ff0c41948f Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:42:09 -0400 Subject: [PATCH 4/7] Document the native numeric bounds Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-editing.md | 37 +++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/reference/native-editing.md b/docs/reference/native-editing.md index 0e110ff..1e42dc7 100644 --- a/docs/reference/native-editing.md +++ b/docs/reference/native-editing.md @@ -114,9 +114,40 @@ 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`, `distortion` (lens scope), `film grain granularity`, `film grain impact`, `moire amount`, `noise reduction color`, `noise reduction details`, `noise reduction luminance`, `noise reduction single pixel`, `sharpening halo suppression` | +| 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` | +| 1...100 | `opacity` (layer scope) | +| 4...80 | `moire pattern` | +| 800...14000 | `temperature` | + +The bounds come from `sdef/16.8.5.30.native-bounds.json`, which records their +source and build; `scripts/generate-native-editing.py` copies them into the +generated resource. 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 From d133b87597ed711314748a6ff70bffe955466268 Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:45:40 -0400 Subject: [PATCH 5/7] Adopt live-measured opacity and distortion bounds The live probe on the qualification fixture accepts layer opacity 0 and lens distortion 120, and rejects -1, 101 and 120.01. Co-Authored-By: Claude Opus 5.5 --- Sources/CaptureOneCore/Resources/NativeEditing.json | 4 ++-- Tests/CaptureOneCoreTests/NativeEditingTests.swift | 4 ++-- docs/reference/native-editing.md | 4 ++-- sdef/16.8.5.30.native-bounds.json | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/CaptureOneCore/Resources/NativeEditing.json b/Sources/CaptureOneCore/Resources/NativeEditing.json index 2d6bbb7..ba2a466 100644 --- a/Sources/CaptureOneCore/Resources/NativeEditing.json +++ b/Sources/CaptureOneCore/Resources/NativeEditing.json @@ -823,7 +823,7 @@ "values": [], "route": "native_set", "minimum": 0, - "maximum": 100 + "maximum": 120 }, { "name": "sharpness falloff", @@ -932,7 +932,7 @@ "writable": true, "values": [], "route": "native_set", - "minimum": 1, + "minimum": 0, "maximum": 100 } ], diff --git a/Tests/CaptureOneCoreTests/NativeEditingTests.swift b/Tests/CaptureOneCoreTests/NativeEditingTests.swift index eccb77b..d05df65 100644 --- a/Tests/CaptureOneCoreTests/NativeEditingTests.swift +++ b/Tests/CaptureOneCoreTests/NativeEditingTests.swift @@ -141,8 +141,8 @@ struct NativeEditingTests { (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", 1, 100, 0, 101, "1...100"), - (lens, "distortion", 0, 100, -1, 101, "0...100"), + (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") diff --git a/docs/reference/native-editing.md b/docs/reference/native-editing.md index 1e42dc7..6126847 100644 --- a/docs/reference/native-editing.md +++ b/docs/reference/native-editing.md @@ -132,12 +132,12 @@ and `capabilities.nativeEditing` report each one as `minimum` and `maximum`. | -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`, `distortion` (lens scope), `film grain granularity`, `film grain impact`, `moire amount`, `noise reduction color`, `noise reduction details`, `noise reduction luminance`, `noise reduction single pixel`, `sharpening halo suppression` | +| 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` | -| 1...100 | `opacity` (layer scope) | | 4...80 | `moire pattern` | | 800...14000 | `temperature` | diff --git a/sdef/16.8.5.30.native-bounds.json b/sdef/16.8.5.30.native-bounds.json index 20375de..b787f51 100644 --- a/sdef/16.8.5.30.native-bounds.json +++ b/sdef/16.8.5.30.native-bounds.json @@ -1,6 +1,6 @@ { "build": "16.8.5.30", - "source": "Bisected by byjustinjones/capture-one-mcp at cfb4e42, docs/adjustment-round-trip.json (MIT), rounded to the intended limits; confirmed at each bound and just past it by scripts/probe-native-bounds.py.", + "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": [ @@ -278,14 +278,14 @@ }, "layer": { "opacity": [ - 1, + 0, 100 ] }, "lens": { "distortion": [ 0, - 100 + 120 ] } } From d91a163c243591b57923fe2e83a70e54de27b765 Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:39:33 -0400 Subject: [PATCH 6/7] Probe the native bounds table live on a disposable fixture scripts/probe-native-bounds.py writes each bound and just past it through guarded direct AppleScript on an owned Session's variant, restores the original, classifies each bound, and keeps JSON evidence. The offline test covers the classification and runs in make check. Co-Authored-By: Claude Opus 5.5 --- Makefile | 1 + Tests/native_bounds_probe_test.py | 58 ++++++++++ scripts/probe-native-bounds.py | 178 ++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 Tests/native_bounds_probe_test.py create mode 100755 scripts/probe-native-bounds.py 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/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/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()) From d8c3f2ce3f94a0a9a1bfd54c67f6ad2b1588beb1 Mon Sep 17 00:00:00 2001 From: kiri11 Date: Thu, 15 Oct 2026 21:48:58 -0400 Subject: [PATCH 7/7] Keep developer paths out of the native bounds reference Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-editing.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/reference/native-editing.md b/docs/reference/native-editing.md index 6126847..7c703fe 100644 --- a/docs/reference/native-editing.md +++ b/docs/reference/native-editing.md @@ -141,10 +141,7 @@ and `capabilities.nativeEditing` report each one as `minimum` and `maximum`. | 4...80 | `moire pattern` | | 800...14000 | `temperature` | -The bounds come from `sdef/16.8.5.30.native-bounds.json`, which records their -source and build; `scripts/generate-native-editing.py` copies them into the -generated resource. 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 +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.