Guidance for Claude Code when working in this repository.
Keep this file current. When a change alters something documented here — pipeline stage order, the uniform contract, an analysis default, the sidecar format, or the build/test commands — update the relevant section in the same change. After adding an adjustment control, re-check the "Adding a new adjustment control" checklist and the uniform layout table.
SwiftInvert is a native macOS film-negative conversion app (SwiftUI + Metal),
a deliberate ~10% rewrite of the Python app NegPy
(~/Documents/code/NegPy), which remains the numerical reference
implementation. Scope: library grid, C-41 conversion with NegPy-quality auto
metering, exposure/color controls, histogram, export. Camera RAW only; no
B&W/E6, papers, crosstalk, retouch, or toning.
swift build # debug build
swift run -c release SwiftInvert # the app (release: debug decode is ~10x slower)
make test # Swift Testing suite — MUST use make, not bare `swift test`
swift build -c release && .build/release/negcli # headless CLI (decode/thumb/render/bench/meter)
make app # package self-contained dist/SwiftInvert.app (bundles Homebrew dylibs)
make install # copy the bundle to /Applications
# Regenerate parity fixtures (after deliberate NegPy kernel/constant changes):
cd ~/Documents/code/NegPy && uv run python ~/Documents/code/SwiftInvert/scripts/dump_fixtures.pyToolchain constraints (this machine has Command Line Tools, no Xcode):
- No XCTest and Testing.framework lives outside default search paths → tests
use Swift Testing with the framework/rpath flags encoded in the
Makefile. Bareswift testfails with "no such module 'Testing'". - No build-time
metalcompiler → shaders are compiled at runtime fromSources/MetalRenderKit/Shaders/NegPipeline.metal(a bundled.copyresource) viaMTLDevice.makeLibrary(source:). - LibRaw comes from Homebrew (
brew install libraw), linked dynamically via theCLibRawsystemLibrary target (pkgConfig: "libraw_r"). LGPL-2.1. - If the repo directory is ever moved/renamed:
rm -rf .build(the module cache embeds absolute paths).
SwiftInvert (app) ──► MetalRenderKit ──► NegativeKit
│ │
└──► RawDecodeKit ───┴──► NegativeKit
│
└──► CLibRaw (Homebrew libraw_r)
negcli ──► RawDecodeKit + NegativeKit + MetalRenderKit
- NegativeKit — pure Swift math, no UI/Metal/IO. The port of NegPy's conversion kernel. Only Foundation/simd/Accelerate.
- MetalRenderKit — the GPU render chain +
ColorIO(CGImage/color-space conversion, used by export). - RawDecodeKit — LibRaw wrapper + embedded-thumbnail extraction.
- SwiftInvert — SwiftUI app (
AppModel,ImageSession, views). - negcli — headless driver;
benchmeasures slider-latency and analysis;meterprints the darkroom read-outs (negative character + spot-densitometer probes) for a real RAW, which is how the read-out math gets exercised on actual scans (the fixtures can't cover a hover).
End-to-end: decode → orient → analyze (CPU) → derive params (CPU, µs) → render (GPU) → display/export. Slider changes re-run ONLY derive+render (~5 ms at preview size). Analysis re-runs only when its inputs change.
LibRaw parameters mirror NegPy's rawpy decode exactly (verified byte-identical
against rawpy on real files — a rawpy comparison must now pass
adjust_maximum_thr=0 explicitly): gamma=(1,1), no_auto_bright,
adjust_maximum_thr=0 (pin the u16 scale to the camera white level; the 0.75
default rescales to the frame's own brightest pixel once it nears clipping —
NegPy 2a6cb22), output_bps=16,
output_color=RAW (sensor-native, no camera color matrix), unity WB
(user_mul=(1,1,1,1), use_camera_wb=0), user_flip=0. Output is scene-linear
RGBImage (interleaved RGB float32 in [0,1], u16/65535 via vDSP).
- Preview path:
half_size=1+ linear demosaic (user_qual=0) — except X-Trans sensors (idata.filters == 9), where half_size aliases the 6×6 CFA; they decode full and downsample. Preview is capped at 1536 px long edge — unless HQ preview is engaged, when the display render runs on the cached full-resolution decode (ImageSession.hqSourceTexture; analysis stays on the proxy, matching export).AppModel.HQMode(canvas-bar badge cycles off → auto → on, ⇧⌘P, View menu picker; session-only — a persisted.onwould make every launch pay full-res costs):.autois the default and stays on the proxy until the canvas is magnified tohqAutoZoomThreshold(2×). The proxy stays on screen through any swap, so it reads as sharpening rather than a reload, and dropping back below is instant off the warm tower. Three source tiers (ImageSession.RenderTier, resolved byAppModel.renderTier(mode:active:); analysis ALWAYS runs on the 1536px proxy, so switching tiers can never move the conversion):.proxy1536px ·.mediumIfFree·.full. The medium tier is the buffer the PREVIEW decode already produced and used to discard — 3012×2010 on a 24MP Bayer body, 4× the proxy's pixels for 0 ms and ~73 MB.prepare()therefore decodes at native preview size and downsamples to 1536 itself rather than asking the decoder to cap (basePreviewis still ONE downsample from that buffer, so the analysis input is byte-identical). Retained only undermediumTierPixelBudget(20 MP): an X-Trans preview decodes full-size, and those bodies get no medium tier —.mediumIfFreefalls through to.full, i.e. today's behaviour..autouses the medium tier and so never triggers a decode (instant, but the half-size linear demosaic — more resolution, not export colour);.onuses.fulland is the export-truth answer, paying the ~500–700 ms decode.DisplayTierholds the base/oriented/texture bookkeeping once and is instantiated per tier.zoom == 1is fit-to-window, so a "proxy is out of real pixels" threshold would fire at rest on any Retina display and make auto indistinguishable from on; 2× keeps the trigger a property of the gesture, not the window size. Resolution is the pureHQMode.resolve(tested without a pipeline): suppressed with no selection and while the test strip or zone placement owns the canvas (both are proxy-renders, and an HQ badge over them would lie — this replaces the old boolean's explicit force-off); auto (not on) is also suppressed in Crop & Straighten, which pins the canvas to fit regardless of zoom. Baseline hold is deliberately NOT suppressed: a compare must show both states at the same resolution.render(retainHQ:)keeps the tier alive across proxy renders in auto/on so zooming out and back doesn't re-pay the ~700 ms decode; it is freed on.off, andreleaseHQ()still strips it from every session the LRU isn't showing. Zoom itself stays DetailView@Stateand is reported toAppModel.canvasZoom, whose didSet acts only on a threshold CROSSING. Tier-swap stability — the two tiers must put the same content on the same pixel, or the picture nudges when HQ arrives (andscaleEffectmultiplies any error by the zoom, i.e. worst exactly when the swap fires).pixelROItruncates andfineRotatedfloors on each tier's own grid, so a 1536px and a 6024px render of one setting genuinely cover different windows, and the proxy was additionally WARPED. Three layers:- Lay out from
RenderOutput.displayAspect(CropGeometry .displayAspect: continuous frame → inscribed → crop, never a pixel count) — bitmap ratios differ up to 0.2% between tiers. - Place each bitmap at the window it really covers
(
RenderOutput.contentWindow→ DetailView'scontentSize/contentOffset), instead of stretching both to fill. Both tiers then resolve to the same affine frame→screen map, so the crop ROI landing on a different pixel in each is corrected EXACTLY rather than merely shrunk.contentWindowmust measure a tier's bitmap against ITS OWN frame (orientedFrameSize(of:)) — using the proxy's frame for the HQ bitmap yields a window ~3.9× the ideal rect. The probe normalizes by the image's frame, notwindow. (An earlier layer that re-derived the HQ crop window from the proxy's was removed once this landed: it only shrank the error, and this removes it.) RGBImage.downsampledis a real area filter. Integer box boundaries (Int(ox·s)) give boxes of varying width whose centres drift from the ideal grid — 0.97 source px at the preview's 1.96 ratio, sliding smoothly across the frame. That is a low-frequency geometric WARP of the proxy, so no rigid correction could fix it; it read as local misalignment (~1.5 pt at 4× zoom) against the true full-res tier. Now separable fractional-weight taps (areaTaps), which is cv2's INTER_AREA — matching NegPy's preview resize (services/rendering/image_processor.py) in kernel, not just intent. This MOVED the default look slightly (it feeds analysis): probes shift ~0.05 D, one test frame crossed a zone boundary. Separable keeps it free (prepare ~17 ms, unchanged; the naive 2-D form cost 28.8 ms).DisplayAspectTestspins tier-equality of the final screen position andImagePipelineSeamTestspins the filter's uniform sample phase; both deliberately keep cases asserting the OLD behaviours really did drift, so the equality tests can't go vacuous.
- Lay out from
- Full path (export): LibRaw default (best) demosaic, full resolution.
- EXIF orientation baked in Swift afterwards (
applyingFlip, dcraw codes: 3=180°, 5=90°CCW, 6=90°CW). C struct's flexible array member must be read via byte offset (MemoryLayout.offset(of: \.data)) —withUnsafePointer(to: pointee.data)copies to a misaligned temporary and traps. - User orientation (
settings.rotation0/90/180/270 CW +flipHorizontal) is baked into pixels right after decode (RGBImage.oriented), so analysis, display-space rects, rendering and export all share one coordinate space with zero mapping code.
Fine-rotation invariant: analysis runs on the orientation-only image
(90° steps + flip — ImageSession.meterPreview), never the fine-rotated
one: the inscribed auto-crop changes with the straighten angle, and
re-metering it would drift the conversion as the user rotates (it also
made the 0° straighten base's look differ from the committed re-bake).
Rendering/export still bake the angle; exportRender shares prepare(),
so preview and export agree.
Two stages, both offset-independent since the 2125a34 port
(ExposureKernel.prepare ≈17 ms once per image/crop; finalize ≈7 ms,
cached with it — white/black-point drags re-run NO analysis at all; the
offsets fold into finalBounds at derive time only). Both were ~5.5× slower
until Stats.sortedAscending moved off vDSP_vsort to RadixSort
(prepare 120 → 21 ms, finalize 45 → 7 ms): sorting was ~70% of analysis and
vDSP was the slowest option measured (21.6 ms/252k Floats vs 15.8 for
Array.sorted() and 1.9 for radix). Every order statistic funnels through
that one function, so keep new percentile/median code going through
Stats rather than calling .sort() directly:
prepare(linearImage:cropRect:analysisRect:analysisBuffer:) →
Prepared (offset-independent, cached per rect state):
- Region priority (NegPy
resolve_analysis_region): a freehandanalysisRectwins and disables the buffer inset; else the outputcropRectscopes metering (borders outside the crop can't skew the inversion) with the buffer applied inside; else the centered buffer alone. Default buffer 0.10 = middle 80% of the frame (NegPy ships 0.05 — deliberate divergence). Degenerate rects (<2 px) are ignored. - Prefilter (
Prefilter.prefilterLogGrid): the buffer crop, thenlog10(clip(x, 1e-6, 1.0))via vForce (buffer holds negative density −D since values ≤1), then a block-median grid: block sideb = ceil(maxDim/1024); per-cell, per-channel median kills dust/speculars and makes stats resolution-invariant. The preview path always hitsb=2, which has a closed-form median-of-4 fast path. NegPy logs BEFORE cropping; we crop first becauselog10is elementwise (identical survivors) and the 0.10 buffer discards 36% of the frame. - Bounds (
BoundsAnalysis.analyze, NegPyanalyze_log_exposure_bounds): two independent percentile axes on the grid, one sort per channel (vDSP): the luma axis (base clip 0.01%) sets the mean center+span (dynamic range); the colour axis (base clip 1.0%) sets per-channel cast offsets relative to the median channel. Since the 127bcd7 port (NegPy 0.43) the colour axis's dense end (floors = print whites) prefers same-pixel refs (samePixelColorFloorRefs, float64 end-to-end like upstream): one shared chroma-gated pixel set — the luma-extreme band[clip, clip+4]percentile, lowest-RMS-chroma 30%, chroma base-anchored against the thin-end refs with a two-pass provisional-gamma refinement — so coloured highlight content can't masquerade as film cast; falls back to the percentile pass when the band has no trustworthy neutrals (< 64 px or median chroma over the caps). Thin-end ceils stay percentile-based (film density is bounded below by base). Recombined:floor[ch] = mean(luma floors) + (colour floor[ch] − median(colour floors))(same for ceils) →LogNegativeBounds(floors < ceils). - Meters (all on the same grid):
anchor: P50 of grid luma normalized by the base bounds, pulled 20% fromassumedAnchor0.46, clamped ±0.12 — the auto-exposure key.texturalRange: |P90 − P10| of raw log luma — drives Auto Contrast.shadowRefs: P98 per channel — the cast-removal fallback tie.
finalize(prepared) → ExposureAnalysis: the neutral axis
(NegPy measure_neutral_axis_from_log) measured against the PRE-trim base
bounds — NegPy 2125a34: the film's cast is a source property, so creative
WP/BP trims must not perturb it (their GPU always measured pre-trim; the
CPU side we'd ported was the bug). Two-pass since the 127bcd7 port
(NegPy 0.43): three normalized-luma bands (highlight 0.10–0.30,
mid 0.40–0.60, shadow 0.72–0.92); per band, keep the lowest-RMS-chroma
30% (hue-uniform pairwise RMS, not max−min; ≥64 px). Pass 1 selects under a
loose cap (0.55 — admits strong-but-correctable casts, rejects saturated
content); the affine R/B→G correction implied by its mid+shadow refs
re-ranks chroma; pass 2 selects true neutrals under the strict cap (0.29)
and takes per-channel raw-log medians. confidence = tightness × sampleSize × agreement — corrected mid/shadow chroma vs the cap,
n/(n+256) on the mid set, and mid↔shadow deviation agreement (0.10 dead
zone, 0.20 roll-off) — gates Auto cast removal. Returns nil mid or shadow
(either pass) → no neutral axis (shadow-ref fallback used downstream).
ImageSession caches: decode (per image) → oriented preview (per
orientation) → Prepared + ExposureAnalysis (per rects) → GPU source
textures (per crop). The "Analyzing…" indicator shows only when prepare
will run.
AppModel.sessionLRU retains the 2 most recent sessions (active + the one
navigated from), so arrowing back to a frame doesn't rebuild the whole tower.
Retention alone is safe because every tier is keyed — a returning session
re-validates against the current settings and cannot serve stale pixels. Only
the on-screen frame may hold the HQ tier (releaseHQ() strips it from the
others); the proxy caches are tens of MB, the HQ decode is hundreds.
Turns (ExposureSettings, ExposureAnalysis) into RenderParams — the full
GPU uniform payload. Order matters:
finalBounds = baseBounds + (wp, bp)offsets (floors+wp, ceils+bp).dMin = paperDmin ? 0.06 : 0;anchor = autoExposure ? metered : nil.strength = confidence × slider— confidence scaling is always on (NegPy 0.36 removed the auto toggle; slider 0–2, >1 overcorrects past the neutral axis; kernel clamps bound any value).perChannelCurveParams(CurveLogic, the C-41 gray-balance heart) — NegPy-exact. Green is the reference (its pivot rides the anchor). Modes:- Neutral-axis (default): R/B fitted to green's axis — quadratic through 3 green-matched points when a highlight ref exists (divided-differences solve; curvature clamped to ±0.45·slope), else a 2-point line; midtone deviations clamped ±0.2.
- Shadow-ref fallback: one-point slope tie at P98 (clamp ±0.1).
- Base: single shared linear curve.
Inputs:
baseSlope = gradeToSlope(grade, effectiveGradeRange(...))— grade is ISO R 50–180 (115 ≈ grade 2),k = 2.9·range/(R/100)clamped [2,10]; Auto Contrast damps the floor-ceil/textural ratio toward 2.0.lumRangecomes from the pre-offset base bounds (NegPy quirk).computePivotsolves so the reference tone prints at density 0.74.
- Overall contrast folds exactly into the core:
v→v+k(v−v*)⇒ slopes,curvatures ×(1+k), pivot += k·v*/s′ (anchor invariant). k = slider×0.5, slider −1…+2. cmyOffsets(pre-curve, normalized space): WB filtration (slider×0.2 / channelRange) with Temp folded along the Planckian direction (yellow + magenta×0.0029/0.0057) and Tint on magenta, plus Exposure stops (stops × −log10(2)/channelRange— the dodge/burn EV domain; + = brighter).- Grade-coupled toe/shoulder knees; band CMY (
colorShadows/Mids/Highs×0.2 into density units); tone controls passthrough (shadowContrast's negative side remapped so slider −3 lands on the monotone floor −0.8, also hard-clamped in the kernels);preSaturation,vibrance,saturationpassthrough.
One command buffer, passes in order (RenderPipeline.render /
renderDisplay):
normalizeLog—log10(max(c,1e-6))(no upper clamp, matches NegPy GPU) → per-channel stretch(log − floor)/(ceil − floor)(offsets already folded intofinalBounds; uniform wp/bp fields are legacy-zero).printCurve— the asymmetric H&D print curve, per pixel: a. Pre-saturation:c → mean + k(c − mean)(density-deviation gain; default slider 1.15, kernel identity at 1.0). b. Per channel:val += cmyOffsets→ quadratic corev = slope(val − pivot) + curv·val²→ midtone paper-Sv += 0.15·0.6·tanh((v − v*)/0.6)→ tone controls (maskswS = σ(3.5(v−1.40)),wH = σ(3.5(0.30−v))on the incoming v, parallel form: shadows/highlights lifts + anchor-pivoted contrasts) → 3-band color (same masks,wM = max(1−wS−wH,0); NegPy's 2-band regional CMY generalized) → shoulder softplus towardd_min_eff(paper white) → toe softplus towardd_max_eff(paper black) → Print Saturation (NegPy 0.45 port, on density above paper base, identity at default 1.0): uniform k around the per-pixel achromatic mean — the global reduction of NegPy's saturation matrix (their surviving density-space control, renamed dye_separation in 3fb5ca8; per-channel trims not ported; the per-pixel spread-masked Dye Separation was ported and retired 2026-07-29 when upstream deleted it as redundant — its sidecar key decodes ignored). c.t = 10^−D; True Black (BPC, optional):t → (t−b)/(1−b)withb = 10^−dMaxreferenced to the physical d_max so toe lifts survive (negative toe raises the clip point); clamp [0,1] → linear reflectance out.colorPop(dispatched when a color-pop control is off-default — vibrance/saturation/band controls — or skinProtection > 0, which is the DEFAULT (0.5): in practice the pass runs every frame, accepted with the port — though the ~7.2 ms/frame recorded then no longer reproduces; bench measures 4.3–4.6 ms/frame with the pass confirmed dispatching) — CIELAB (Adobe RGB (1998) primaries, D65 since the b3490eb port; matrices duplicated in MSL andLabColor.swift— keep in sync): the Color Mixer first (LabColor.applyColorMixer, SwiftInvert-only, no NegPy equivalent — chroma-gated hue-targeted R/Y/G/B bands: per-band raised-cosine hue windows × a shared chroma ramp that zeroes at the neutral axis, so whites/grays/faint casts never move; all weights read the ORIGINAL hue and compose jointly, so overlapping feathers are order-independent; constants tuned on real scans (blues at Lab hue ~235, gate 6→16 — colorimetric-primary values starve real content, whose colorful pixels sit at chroma 12-30):bandCentersDeg/bandHalfWidthsDeg/bandChromaGate*/bandMaxHueShiftDegmirrored as MSL literals; UI: segmented band picker + gradient tracks,ColorMixerSection.swift), then vibrance (muted-chroma boost, /60 range; upstream DELETED theirs — ours is SwiftInvert-maintained, reference formula frozen in the dump script) then saturation (a*,b* scale; gamut-aware on boosts since the 1b900ab port: skin-band softening (Gaussian around Lab hue 52°, width 25°, strength 0.5, chroma gate 2) → per-pixel in-gamut headroom by 10-iteration bisection against the real RGB cube → softplus knee, so overshooting pixels keep their hue instead of hue-shifting through the per-channel clamp; in-gamut non-skin pixels are bit-identical to the flat scale; desaturation stays flat; PURE gamut math since the bfcd90a port removed the in-boost skin term), then Skin Protection (skinChromaRein, default 0.5, slider 0–1): a one-directional soft chroma ceiling (22/strength, softplus knee from 0.6×ceiling, scale blended by mask weight; a*/b* together so hue and L* never move) inside the measured skin locus — hue Gaussian 52° σ20 × one-sided chroma window (full ≤35, zero ≥60; pure red C≈104 drops out) × lightness rolloff (L 15…95) — applied after the scale and independent of it, so it also reins skin arriving over-chromatic from the print curve. All constants are MSL literals mirrored from LabColor.swift. Separate pass on purpose: inlining the Lab code into printCurve cost ~3 ms/frame in register pressure even when branched off. Writes into the (already consumed)normalizedtexture, which becomes the content texture.histogram256— 4×256 atomic bins (R,G,B + Rec.709 luma) from the linear content, OETF-encoded in-shader so bins match display values — including the levels remap (below), so the interactive histogram reshapes live under a drag. Bins are RAW COUNTS, and the HQ tier bins ~15× as many pixels as the proxy, so anything drawn from them must be a function of the histogram's SHAPE only (HistogramView.barHeightnormalizes to the peak bin, then compresses with a FIXED constant;log1p(count)/log1p(maxCount)looks scale-free but adds ln(pixelCount) to both ends, and lifted sparse bins 7–14 percentage points with HQ on).clipFractionsand the hover read-out already divide by the total.outputEncode— working-space OETF (Adobe RGB 1998 TRC: pure 563/256 = 2.19921875 power, no linear segment — b3490eb), then the levels remap (interactive histogram, SwiftInvert-only): per channel a piecewise-linear map through user-planted anchors (input→output pairs, endpoints pinned, empty = identity;levels_remapMSL + the CPU mirrorReferenceCurve.levelsRemap;sanitizeLevelsat derive sorts, clamps 0.02 off the endpoints, forces monotone outputs, caps at 8; uniforms travel as a flat 51-float buffer — layout documented at both ends). Display fast path writes the same kernel into an rgba8unorm texture (GPU quantization, 4× smaller readback, zero CPU conversion); export/tests use the float path.
Threading/reuse contract: render is serialized by an internal lock and
returns read-back buffers, never live textures — intermediate textures
are cached per size (≤4 MP; export sizes are not retained) and a later render
overwrites them. Violating this segfaulted the concurrent test runner once.
Uniform structs are mirrored byte-for-byte in ShaderTypes.swift;
LayoutTests pins strides (Norm 48, Curve 256) plus the 51-float levels-buffer length and key offsets — update both
sides plus the asserts together.
Big-buffer discipline (learned repeatedly, now applied throughout): never
allocate a large pixel buffer with [T](repeating:) when the code that
follows writes every lane — use unsafeUninitializedCapacity (or
RGBImage(width:height:initializingWith:)). At export size the wasted
zero-fill costs tens of ms per buffer in first-touch page faults alone, and
readback was doing it twice (387 MB + 290 MB). Live instances: upload,
readback, both display/float read-backs, RawDecoder's u16→float,
RGBImage.downsampled, BoundsAnalysis.sortedChannels.
Working space is linear Adobe RGB (1998) → Adobe-RGB-encoded at the end of the chain (b3490eb port, 2026-07-20: upstream moved off ProPhoto because the pipeline ASSIGNS primaries to sensor-native data at output, and ProPhoto's imaginary primaries inflated chroma and skewed hues — reds toward magenta, skies toward cyan).
- Display: the encoded output becomes a CGImage tagged
CGColorSpace.adobeRGB1998; ColorSync converts to the monitor — pinned against NegPy's littleCMS display transform (AdobeCompat-v4 → sRGB-v4, relative colorimetric + BPC) on reference colors (ColorIOTestsoracle values regenerated 2026-07-20 with NegPy's bundled ICC profiles). - Export (
Exporter+ColorIO): default sRGB (NegPy's default; wide-gamut files look washed out in profile-ignorant viewers) via a 16-bit working-space CGImage drawn into an sRGB CGContext (one quantization). Adobe RGB remains selectable (the ExportColorSpace case is still NAMED rommRGB for sticky-options JSON compatibility). JPEG 8-bit (default q=0.92) or TIFF 16-bit, optional long-edge resize, destination next-to-source or a chosen folder.
Tests/Fixtures/ is dumped from NegPy's actual engine by
scripts/dump_fixtures.py (run in NegPy's uv env). The suite (make test)
verifies every stage boundary:
- closed-form oracles (percentile semantics, grade/pivot/softplus/OETF math),
synthetic64(NegPy's golden image, dumped as .bin): prefiltered grid @1e-5, bounds/meters/curve-params @1e-4, full CPU chain @1e-4,synthetic_grid(1600×1066, regenerated bit-exactly in Swift from an integer-hash formula): exercises b=2 block-median + the quadratic cast-removal path (slope/pivot tolerance 2.5e-4: Accelerate-vs-libm ulp amplification),ramp257print-curve shapes,lab_color(vibrance/saturation vs NegPy's CIELAB ops),- GPU vs fixtures and GPU vs CPU reference at NegPy's own gates (mean<0.01, max<0.04); the rgba8 display path within 1.5/255 of the float path.
Beyond parity, the suite covers the seams the fixtures reach only transitively, plus the app layer:
SidecarCodecTests+HistoryLabelTestsare drift-catchers: each pinsExposureSettings' stored-property count (49) and exercises every field — adding a settings field fails both until the decoder,HistoryLabels, and the tests' mutation lists all get their line (see the control checklist).ImagePipelineSeamTests: the prepare/finalize cache split (a reusedPreparedmust equal fresh analysis; offsets may only move the neutral axis) andRGBImage.downsampled(dims/identity/mean preservation).SwiftInvertTests— the app-target suite. SwiftPM tests the@mainexecutable directly (@testable import SwiftInvert— works since Swift 5.5, verified under the Makefile's CLT flags):SidecarStorefile behavior (legacy.negswift.jsonfallback + delete-on-save),ExportOptions.destinationURL,DensitometerStateprobe mapping,ImageConversionshapes. Views/AppModel/ImageSession stay UI-verified — and pointer paths need a HUMAN pointer: synthetic mouse events never reach the unbundled binary (no TCC grant, won't take focus), so verify hover logic headlessly (negcli meterpattern) and hand off the gesture.
Upstream review log: see UPSTREAM.md — it records the last NegPy
commit reviewed (the baseline for "what changed upstream?" requests) and
the port/skip decisions per review. Update it after every upstream review.
For "what changed in NegPy?" requests, run the /negpy-review skill
(.claude/skills/negpy-review/) — it fetches upstream, triages the diff
around the inversion pipeline, and maintains UPSTREAM.md.
Analysis semantics and kernel constants are synced with NegPy 0.43
(0369b10 tip; the estimator rewrite landed in 127bcd7: two-pass
RMS-chroma neutral axis with the rebuilt confidence, same-pixel colour
floors, neutral_axis_chroma_cap 0.29 + the five new estimator constants —
on top of the earlier syncs: 2125a34 pre-trim neutral axis, b3490eb-coupled
auto constants, the 0.38 set with paper_dmin off + true_black on, the
0.36 set). Fixtures were re-dumped from 0369b10 (the manifest records
paper_dmin and true_black per config; the parity harnesses read both,
so default flips on either side can't silently skew parity). NegPy's per-layer R/G/B trims,
Split Grade and Zone Density (their convergent take on our tone controls)
are NOT ported — our tone controls + 3-band grading cover the achromatic
cases; per-channel crossover trims are a candidate future feature.
Deliberate divergences from NegPy (fixture tests pin the NegPy-neutral values where needed):
preSaturationdefault 1.15 (NegPy has no equivalent; parity tests set 1.0),RETIRED 2026-07-20: it countered reds skewing magenta, which was the ProPhoto-interpretation hue skew the b3490eb port removed at the root — default is 0 again (sidecars that explicitly set a redHue keep their value; keyless sidecars now decode to neutral),redHuedefault +0.5- Color Mixer band constants were tuned in the old ROMM/D50 Lab; re-checked numerically after b3490eb (real-content reds/greens/sky-blues still land in-band; only colorimetric-primary blue sits at the feather edge, which matches the tuned-on-real-content philosophy) — but an on-scan re-tune pass is still worth doing,
- default analysis buffer 0.10 vs NegPy 0.05 (tests pass 0.05 explicitly),
- NegPy's default lab sharpen (0.25 since 8bc9678; was 0.5 earlier in 0.38) is not implemented,
- vibrance is SwiftInvert-maintained since NegPy de79e13 (upstream deleted Lab Vibrance in favour of a density-space signed Dye Separation — which they then also deleted in 3fb5ca8, leaving only the matrix-slot control we ship as Print Saturation; ours stays for sidecar compat, identity in every parity config — the lab_color vibrance fixture is frozen at the 0369b10 dump and cannot be re-dumped from newer NegPy),
- SwiftInvert-only controls: exposure stops, tone controls (shadows/highlights ± contrasts), overall contrast, temp/tint, 3-band color grading, pre-saturation, the interactive-histogram levels remap — all identity-at-default so the NegPy fixtures still pass.
AppModel(@MainActor @Observable): selection +multiSelection, settings (didSet → coalesced latest-wins render task + debounced 1 s sidecar save;isRestoringSettingssuppresses saves on open), tool modes (crop/analysis-region draw), baseline hold-to-compare (showingBaselinerenders stock settings with current geometry), export batch task with progress/cancel.ImageSession(actor): the per-image cache tower (see §2) + render.- Default profiles (
DefaultProfile.swift,ProfileStore.swift,ProfileWindows.swift): the house look applied wherever a frame is seen fresh (open with no sidecar, Reset All, sidecar-less frames in batch export and Copy Adjustments) — resolved asDefaultProfile.settings= the ACTIVESettingsProfileinProfileStore(two reserved rows from code, never persisted/editable: "None" = stock, first in the list and the out-of-the-box active choice, then "SwiftInvert Default" = the house look; user profiles + active choice as UserDefaults JSON, so a picked default survives restarts). File → Choose Default Settings… opens the picker (Accept = set active; Create New seeds from the selection); Create/Edit opens a profile-editor window: the full app UI underAppModel(profileEditor: true), where one shared adjustments draft (profileDraft) applies to EVERY frame — geometry stays per-frame from sidecars, and NO sidecar is ever written in that mode (guards in scheduleSave/paste/export-flush). Multi-window plumbing:KeyModelTracker+WindowKeyObserverroute menu commands and each ContentView's key monitor to the key window's model. "Start from Scratch" (under View Original) cancels the profile to stockExposureSettings(), which stays NegPy-parity-neutral — fixtures, negcli and the parity suite never see profiles.DefaultProfileTests+ProfileStoreTestspin the built-in's field list, adjustments-only stripping, built-in protection, and persistence round-trip. - Interactive Histogram (
InteractiveHistogram.swift, double-click the sidebar histogram → its own window, key-model capture at open): per-channel levels remap by direct drag. A drag grabs a tone (the grab position is inverse-mapped through the remap in effect, so you grab what you SEE — pressing near an existing anchor's line grabs THAT anchor) and moves its output; releasing PLANTS a fixed anchor, and later drags reshape only their own segment between neighbours. Anchors render as vertical lines with an ✕ below to remove; outputs clamp between neighbours so the map stays monotone; ≤ 8 per channel (levelsRed/Green/Blueanchor arrays). Drags commit on release via setControlEditing; per-channel + all reset buttons. - Test strip (NegPy e4bc450 port:
TestStripmath in NegativeKit,ImageSession.renderTestStrip— one analysis + one uploaded source + 25 derive/renderDisplay passes, mosaic assembled incrementally, always the preview proxy —TestStripLayerpresentation, Print-group button + ⇧T in the key monitor): the frame as a 5×5 grid of REAL renders, Brightness across (mirrored from upstream's density order to match our right-=-brighter convention), Grade R75–R155 down, ladders absolute and centred on the defaults so the current settings are a patch. Two-stage picking: single-click a patch → full-frame PREVIEW of that look (one warm-tower render; click again = back to the grid to compare others), double-click (grid or preview) → one history entry ("Test strip pick") setting density+grade (auto toggles untouched — patches rendered under them); singles are held for the double-click interval on purpose (a mis-fired confirm would commit settings); session-only state cleared by ANY edit/navigation/tool/baseline/HQ change (guarded by a generation counter against stale builds); Escape clears first. No gridlines — only the hovered patch outlines; the current-settings rung is accented. While a strip is up the rotate buttons and ⌘[/⌘] turn the LADDER, not the image (a2455ab port): orientation 0–3 rotates the logical density×grade assignment under the fixed display grid, labels/picks follow, mosaic rebuilds from the warm tower (upstream caches all four assemblies; we re-render — same UX at ~a strip build, no 25-tile retention). TestStripRenderTests pins patch == full render at that patch's settings, byte-identical; rotation tests pin the bijection and axis purity per orientation. - Darkroom read-outs (
Densitometryin NegativeKit — pure measurement, no render path, so no parity surface): the spot densitometer (hover the canvas → D + zone in the control bar, with an 11-cellZoneStrip) and the Negative character row under the Grade slider ("0.90 · normal"), which is the measured pre-offset density range vsCurveLogic.defaultGradeRange. Both mirror NegPy (densitometer.py,stats._negative_row), including its quirk of taking luma on the ENCODED triplet before decoding. The probe reads the displayed rgba8 bitmap (working-space-encoded, so the bytes already are the working-space values) rather than a GPU metric —DensitometerStatecaches the provider bytes once per render and is a separate@Observableso pointer moves invalidate only the read-out label, never the canvas. The zone overlay (NegPy ae56f8b port:ZoneGridin NegativeKit +ZoneOverlayview; "Zones" control-bar button / View menu / ⇧Z) shares that ruler AND those cached bytes: integer zone per cell (24 along the long edge, box-averaged), same-zone regions merged with one Roman numeral each (anchor snapped to an owned cell), paper black/white numerals red. The grid recomputes per adopt/toggle only, and hides in tool modes and straighten previews (same rule as the probe). ⇧Z lives in the window key monitor, NOT as a menu equivalent — a bare-letter equivalent would fire while typing in text fields; the monitor checks the first responder. NegPy's 120-bindensity_histogramis deliberately NOT ported: it exists to feed their H&D chart, which we don't ship. - Zone placement (NegPy 5a095f3/9dff124 port:
ZonePlacementin NegativeKit +ZonePlacementLayer/AppModel state): click a zone-strip cell, click that spot on the photo, and Brightness (1 pin) or Brightness+Grade (2 pins) or those plus ONE knee control (3 pins) solve so the tone prints there. Pins freeze their normalized-log luma (ImageSession.sampleZonePin, 5×5 patch mean — the displayed rgba8 is post-curve and can't serve); the forward model is the ACHROMATIC green-reference curve run through the real kernel (predictedZone: derive → params collapsed to green → 1-pxReferenceCurve→ zone ruler — includes exposureStops and the green levels remap, which upstream's model omits/lacks). Solved by bisection at half slider precision, autos forced off, clamped+rounded, achieved zones recomputed at the rounded values;encoded(ofZone:)is the ruler's exact inverse. Knee candidates are OUR tone-control knees (shadowContrast / highlightContrast — upstream solves Split Grade/Snap, which we don't ship), picked by measured purchase; DIVERGENCE: the knee bisects the COMPOSITE residual (2-pin solve nested inside) instead of upstream's alternating passes — our anchors sit between typical pin positions, the alternation diverges there, and the µs-scale model makes nesting cheap. UX mirrors the test strip's canvas-state rules: solved-look preview drawn over the frame, drag re-samples (retargeted pins keep their zone), Apply = one history entry ("Zone placement"), Escape/any edit/tool/baseline/HQ/navigation clears; solve runs in the session actor. No settings field, no parity surface, no fixture re-dump. - Edit history: per-image undo/redo in AppModel (
historyEntries/historyIndex, session-scoped per URL). Slider/handle drags commit on RELEASE (drag = preview:setControlEditingvia thecontrolEditingChangedenvironment hook holds commits while any control is held); non-drag changes coalesce via a 0.7 s debounced commit; labels come fromhistoryLabel(from:to:)(HistoryLabels.swift — add a line there for every new settings field) orpendingHistoryLabelfor named actions (Rotate/Crop/Reset). New edits truncate the redo tail. Undo flushes any in-flight uncommitted change first. UI: HistoryPanel (⌘Z/⇧⌘Z, click-to-jump), below the collapsible Adjustments section. - Unified Crop & Straighten (Lightroom model):
ToolMode.croprenders the full UNROTATED frame (scheduleRender substitutes fineRotation 0, uncropped) fitted by its rotated bounding box; the image rotates behind an axis-alignedCropBoxOverlay(dim surround, thirds, corner handles, clamped move) whose math lives inNegativeKit/CropGeometry(rotated-space boxes, fitScale/constrain, content-preservingremapCrop; tested). The desired box is DetailView@State(cropBox, nil = follow committed); exit commits viacommitCrop()(near-full box ⇒ crop cleared). Straighten commits outside the mode go throughAppModel.commitFineRotation, which remaps a committed crop so its content doesn't drift with the inscribed auto-crop;RenderOutput.frameSize(orientation-only dims) is the coordinate base. The analysis tool keeps the old draw-a-rectSelectionOverlay. - Menu bar (
SwiftInvertApp.commands): File = Open Folder ⌘O / Choose Default Settings… / Export ⌘E / Show in Finder ⇧⌘R; Edit = Undo/Redo (replacing the system group — the ⌘Z shortcuts live HERE, not on HistoryPanel's buttons), Copy/Paste Adjustments ⇧⌘C/⇧⌘V (geometry never pasted), Reset All ⌥⌘R; View = Show Library ⇧⌘L / Show Grid Lines ⇧⌘G / Zone Overlay (⇧Z via the key monitor) / HQ Preview (inline 3-way picker — a Picker can't carry a key equivalent, so a sibling "Cycle HQ Preview" item owns ⇧⌘P and cycles like the badge) (@AppStorage keys shared with the in-window controls); Image = Previous/Next Image ←/→ / Rotate Left/Right ⌘[/⌘] / Flip ⇧⌘H / Crop ⌘K / Crop for Analysis ⇧⌘K + clear items (tool toggles checkmark while active; Escape exits). Frame navigation also answers ↑/↓ (↑ = previous, ↓ = next) via the window's keyDown monitor inSwiftInvertApp— a menu item takes exactly one key equivalent, so the vertical pair can't live on the menu; the monitor mirrors the menu items' enablement (no files / export sheet up ⇒ pass through). - Sidecars:
<basename>.swiftinvert.jsonnext to the source (SidecarStore); pre-rename.negswift.jsonread as fallback and removed on next save. Missing keys decode to defaults (custominit(from:)inExposureSettings— every new field needs a line there). - UserDefaults:
libraryFolder,canvasColor,exportOptions(JSON blob); one-time migration from the pre-rename "NegSwift" domain. - SwiftUI pitfalls already hit (don't regress):
- Lazy grids + @Observable: cells must read observable state inside their own body — precomputed Bools leave stale cells (selection halo bug).
.position()expands a view's frame to its container: attachcontentShape/gestures BEFORE positioning (histogram handle bug).NSEvent.modifierFlagspolling in tap handlers is unreliable — useTapGesture().modifiers(.command)etc.- The app runs unbundled via
swift run: activation policy is set manually; UserDefaults/TCC key off the process name.
ExposureSettings: field + default, plus a line in the custom decoder (sidecar back-compat), plushistoryLabel(HistoryLabels.swift) — andRenderParams+ its init if the kernel needs it. Two tripwires enforce this:SidecarCodecTestsandHistoryLabelTestsboth pin the stored-property count (49) and mutate every field, somake testfails until all the lists have their line.deriveRenderParams: map settings → params (fold into existing params where the algebra allows — see overall contrast/exposure — before adding uniforms).- Kernels, BOTH sides in the same change:
ReferenceCurve.swift(CPU) andNegPipeline.metal(MSL), in identical order/parallel form. New uniforms: extendCurveUniformsin both files (16-byte alignment) + updateLayoutTestsstrides/offsets. Guard non-default-off work behind uniform branches or a separate pass (occupancy!). - UI:
LabeledSlider/GradientSlider(sets the reset-⨯ default), section inControlsSidebar;negcli renderflag if useful. - Tests: default-is-identity (fixtures must still pass), direction/region properties, monotonicity if it touches the tone curve, sidecar round-trip, GPU-parity case with the control active.
- Run
make test,negcli bench(watch for regressions), launch the app headlessly (render log line), commit with the reasoning.
K(ExposureConstants.swift) is the single Swift source; the MSL duplicates: tone anchors/sharpness (TONE_SHARPNESS,SHADOW_ANCHOR,HIGHLIGHT_ANCHOR), Lab matrices/eps/kappa/white, and the working-space OETF exponent (0.45470693 in MSL = 256/563). GPU/CPU parity tests catch drift but update them together.- If NegPy's
EXPOSURE_CONSTANTSchange deliberately: updateK, re-dump fixtures, re-runmake test.