Skip to content

V5#293

Draft
muukii wants to merge 34 commits into
mainfrom
v5
Draft

V5#293
muukii wants to merge 34 commits into
mainfrom
v5

Conversation

@muukii

@muukii muukii commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

## Summary

- Add a new `BrightroomParametric` SwiftPM product/target for
platform-neutral parametric editing features.
- Move Feature documents, registry-backed definitions, graph
compilation, image rendering, video rendering, and Metal-backed mask
kernels into the new module.
- Keep the `EditingStack` bridge inside `BrightroomEngine` so legacy
`EditingStack.Edit.Filters` can convert into parametric feature
pipelines without making the new module depend on Engine/UI.
- Add SwiftUI image/video playgrounds plus a lightweight macOS
`ParametricMacDemo` target for interactive checks.

## Notes

`Package.swift` now declares macOS support so the new
`BrightroomParametric` product can build for macOS. This PR does not
make the UIKit-backed `BrightroomEngine` or `BrightroomUI` surfaces a
full macOS port.

## Validation

- `plutil -lint Dev/Brightroom.xcodeproj/project.pbxproj`
- `xcodebuild -project Dev/Brightroom.xcodeproj -scheme
ParametricMacDemo -destination 'platform=macOS' -derivedDataPath
build/CodexDerivedData build`
- `xcodebuild -project Dev/Brightroom.xcodeproj -scheme
BrightroomEngineTests -destination 'platform=iOS
Simulator,id=05718949-3329-4852-9F7A-FA1649441821' -destination-timeout
60 -derivedDataPath build/CodexDerivedData
-only-testing:BrightroomEngineTests/ParametricFeatureTreeTests test`
- `xcodebuild -project Dev/Brightroom.xcodeproj -scheme SwiftUIDemo
-destination 'platform=iOS
Simulator,id=05718949-3329-4852-9F7A-FA1649441821' -destination-timeout
60 -derivedDataPath build/CodexDerivedData build`
@muukii muukii marked this pull request as draft June 10, 2026 11:20
muukii and others added 28 commits June 10, 2026 20:21
## Summary
- Remove the load-time path that eagerly decoded the editing source into
an `MTLTexture` via `MTKTextureLoader`; collapse `CGImage._makeCIImage`
down to `CIImage(cgImage:).oriented()`.
- Delete `EditingStack.Options.usesMTLTextureForEditingImage` and its
plumbing (`makeMTLTexture` / `supportsImage` / `MTLImageCreationError`,
the now-unused `mtlDevice`, and the `MetalKit` import).
- Add `EditingImageWarmUp`: render the source once on a background queue
before publishing `loadedState`, warming Core Image's pipeline.

## Why
In the v5 canvas, `EditingCanvasMTKView` re-renders the source `CIImage`
into its own viewport texture every frame (`viewportSourceImage`). The
source's backing — `MTLTexture` vs `CGImage` — is therefore irrelevant
to display, so the load-time texture conversion is unnecessary. It only
carried downsides:

- `MTKTextureLoader` quantizes to **8-bit**, defeating the P3/EDR pixel
format `MetalImageView` configures.
- **16-bit images were never supported** and silently fell back to the
`CGImage` path, so behavior was already split in two.
- `CIImage(mtlTexture:)` required a **y-flip** that has been a recurring
source of orientation bugs (e.g. flipped export masks).

This direction also matches the Core Image guidance in WWDC 2026 Session
305 ("Enhance RAW image processing with Core Image"): use a per-view
Metal-backed `CIContext` rendering into an `MTKView`, and do not
pre-convert the source into a texture yourself.

## Preload (warm-up)
Dropping the texture path moves the GPU upload — previously done
off-thread by `MTKTextureLoader` during load — plus Core Image's
one-time pipeline-state compilation onto the first main-thread
`draw(in:)`, which visibly stalls the first frame. `EditingImageWarmUp`
addresses this:

- Renders the source downscaled to 256px through a dedicated
process-wide `CIContext(mtlDevice:)` (downscaling forces the whole
source to be sampled → full upload + pipeline compilation), with a tiny
read-back.
- Runs on a background queue in `handleImageLoaded` **before**
`loadedState` is assigned. Since `isLoading == (loadedState == nil)`,
the loading spinner stays up until warm-up finishes, so the canvas only
draws once the pipeline is hot.
- Never blocks the main thread. No new public API.

## Test plan
- [x] `xcodebuild -scheme BrightroomUI` build succeeds
- [x] `BrightroomEngineTests` pass with 0 failures (incl.
orientation/render suites: `RendererOrientationTests`,
`LocalAdjustmentMaskOrientationTests`, `RendererDeviceEquivalenceTests`)
- [ ] On-device verification of the first-load hitch (not yet done)

## Notes
- `Options.usesMTLTextureForEditingImage` was public, so its removal is
technically a breaking change. v5 already carries larger breaking
changes (e.g. PixelEditor removal), so this is in scope.
- The warm-up gates presentation: the spinner is slightly longer in
exchange for a hitch-free first frame.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- EditingStack.Edit stores only [EditingFeature]; undo/redo over document snapshots
- PixelEditor removed; PhotosCrop is the single built-in UI
- BrightroomParametric: JSON/registry model deleted; Swift-native value tree
  with protocol-dispatched evaluation; ParametricDocumentCodec with typed
  versioned migration, encode-side boundary enforcement, and formatVersion
- Core Image kernels moved to ParametricKernels.metal (precompiled metallib
  first, runtime-source fallback for pure SwiftPM)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the legacy Filtering/AnyFilter/Filter* layer with the
BrightroomParametric vocabulary across the engine and UI.

- EditingStack.Edit features carry parametric payloads:
  .effects(EffectPipeline) / .localAdjustment(LocalAdjustmentFeature) /
  .crop(EditingCrop). FeatureID is the feature identity; the UUID glue is gone.
- Renderer evaluates .effects/.localAdjustment operations, and preview and
  export share one composition path (pinned by EditingPreviewExportParityTests).
  Local-adjustment effect failures now surface on export (throwing) while the
  preview degrades to identity. Disabled brush leaves select nothing in both
  paths, matching the parametric compiler.
- Canvas/CropView/PhotosCrop speak EffectPipeline / BrushMaskStroke / FeatureID.
  PhotosCrop owns effect ordering (PhotosCropEffectOrder); the blur seed is the
  scale-invariant GaussianBlurFeature(value: 40); presets are PresetFeature;
  ColorCubeLoader returns ColorCubeFeature.
- Delete the legacy filter layer: 15 Filter* files, Filtering/AnyFilter,
  EditingStackParametricBridge, ColorCubeStorage, and the previewFilterPresets /
  previewScale machinery.
- Tests: port the suite to parametric types; add preview/export parity,
  brush-falloff parity, visual-evidence, and per-path performance benchmarks
  (111 passing).
- Docs: refresh AGENTS.md guidance and add docs/performance-notes.md with
  profiling results and improvement opportunities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SwiftUIImagePreviewView (and the internal _ImagePreviewView /
_PreviewImageView) was only referenced by demo code. Remove it together
with the demos that used it: ImagePreviewDemoView in RenderingDemoView,
the DemoFilterView custom-effects screen, and their ContentView entry
points.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y disk export

Engine/UI checkpoint for the v5 parametric editing work. Builds (engine, UI, SwiftUI demo) and 136 BrightroomEngineTests pass.

Document & crop model:
- Delete EditingCrop and EditingFeature; EditingStack.Edit now holds an
  EditingDocument directly and crop is a parametric CropFeature (y-up rect,
  QuarterTurn rotation, straighten). Add CropGeometry helper and rebase the
  RenderCrop math on CropFeature. BrightroomUI edits crop through a y-down
  CropEditingState/CropRotation working model with a single shared y-flip/snap.

Rendering:
- Single rendering path: every export compiles the document to one CIImage
  recipe via ParametricImageRenderer. Remove the CoreGraphics fast path
  (renderOnlyCropping / axisAlignedCropOnlyFeature / pixelCropRect) and the
  Rendered.Engine distinction.
- BrightRoomImageRenderer.render is now a single async throws -> Rendered.
  Output (in-memory bitmap vs file) is chosen via Options.output; Rendered hides
  the disk/memory backing (cgImage/uiImage get throws, fileURL, thumbnail).

Large-image memory:
- Fix the editing-canvas local-adjustment blur OOM on huge images (e.g. 12000^2
  'Nasa'): evaluate effects + the local effect at the downsampled ~2560 editing
  source then upscale, instead of blurring the full-canvas image. The blur
  radius is a fraction of image extent, so the upscaled preview matches the
  full-resolution export.
- Bounded-memory disk export (Output.file): CIImageStreamingFileWriter renders
  the recipe strip-by-strip into an mmap'd temp file and encodes lazily through
  a direct CGDataProvider (no full-resolution CGImage, no CGImageDestination
  over the whole bitmap). Replaces write{JPEG,HEIF}Representation, which
  materialized the full bitmap in RAM. (On-device peak-memory verification still
  pending.)

Brush mask:
- Share one brush falloff (BrushStampSharedSource) across the export/preview
  CIKernel and the live Metal shader; the live canvas renders committed + active
  strokes in one real-time pass.

Tests/docs:
- Add CropGeometry, BrushStampFalloff, BrushMaskRasterizerParity, LargeMaskedExport,
  MaskedPreviewExportScaleConsistency, and DiskExport suites; migrate renderer
  tests to the async API. Track remaining follow-ups in docs/editing-engine-todo.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Set the package to swift-tools-version 6.3 with swiftLanguageModes [.v6] and resolve the data-race-safety diagnostics across all three library modules (0 errors; demo and tests build green).

Key changes:
- Lock-guarded global CIContext caches -> nonisolated(unsafe).
- Add Sendable to renderer value types (Options/Rendered/Output/Resolution/RenderingDevice) and CropViewFeatureFocus; ImageSource -> @unchecked Sendable.
- PresetStorage, UIKit layout/frame-rate helpers, and the display-link holder -> @mainactor; view teardown uses isolated deinit.
- BrightRoomImageRenderer.render runs off-actor (@Concurrent) over a Sendable snapshot, with a shared synchronous core kept for benchmarks; makeRenderer returns a sending value.
- Remove the now-unused CIImageDisplaying protocol; add SwiftUIMetalImageView wrappers.

Remaining warnings: EditingStack/ImageProvider background-queue self-captures (isolation-model follow-up) and pre-existing iOS deprecations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

Fixes the editing canvas clamping **Display-P3 to sRGB** (export was
already correct — this was a **preview-only WYSIWYG defect**, not data
loss), and makes the canvas wide-gamut end to end.

**Root cause:** the canvas ran a closed sRGB/8-bit loop. One color
constant tagged every Metal↔Core Image hop as sRGB, and all color
textures + the drawable were `.bgra8Unorm`, so the very first source
bake hard-clipped out-of-sRGB-gamut P3 chroma; every downstream stage
re-clipped.

## Changes

**Four-boundary color contract** (`EditingCanvasImageProcessing`),
replacing the single `colorSpace`:
- `workingColorSpace` == `intermediateColorSpace` =
`extendedLinearDisplayP3` — the **same** space writes AND reads back
every intermediate texture, so each hop round-trips losslessly through a
float texture.
- `drawableColorSpace` = `displayP3`, kept identical to
`CAMetalLayer.colorspace` → a single linear→display conversion, no
double color management.
- `maskColorSpace` = `sRGB` (unchanged) — the mask is a [0,1] selection
field, so the brush feather fed to `CIBlendWithAlphaMask` is
behavior-preserving.

**Canvas** (`EditingCanvasMTKView`):
- CIContext: `workingColorSpace` = extendedLinearDisplayP3,
`workingFormat` = `.RGBAh`.
- Color intermediates (source/base/adjusted) → `.rgba16Float`; drawable
→ `.bgr10a2Unorm` (10-bit P3 SDR).
- Layer colorspace set in `init` and re-asserted in `didMoveToWindow`
(MTKView rebuilds its drawable from `colorPixelFormat`). Both canvas
surfaces share the init, so CropView's instance is fixed too.

**Export** (`ParametricExportRenderer`): always process through an
**extended-linear Display-P3 working space + half-float (`.RGBAh`)
working buffer**, so out-of-sRGB / out-of-range chroma survives the
multi-filter chain. The previous setup (CI's default sRGB-primary
working space, 8-bit) clipped edited wide-gamut sources mid-chain. This
is the processing precision only; the **output** stays
`options.workingFormat` (8-bit, source-space-tagged, for SDR delivery).
The export working space now matches the canvas, so canvas and export
agree.

Measured — an Adobe RGB green `(0,1,0)` (outside both sRGB and P3)
edited (blur) then exported back to Adobe RGB:

| working space / format | exported (R,G,B) |
|---|---|
| default sRGB-primary, 8-bit (old) | `(144, 255, 60)` — badly
contaminated |
| default sRGB-primary, RGBAh | `(4, 255, 60)` |
| **extended-linear Display-P3, RGBAh (this PR)** | **`(0, 255, 0)` —
faithful** |

On the editing canvas, out-of-P3 source colors (Adobe RGB greens/cyans)
are clamped to the P3 gamut boundary at the final drawable write —
correct and unavoidable, since a P3 panel cannot display colors outside
its gamut; the internal pipeline preserves them up to that point.

**Test + demo:** `EditingCanvasColorContractTests` renders P3 red
through the new vs old contract — both encoded to Display-P3 — and
asserts the new path stays saturated while the old desaturates (runs on
the simulator: it tests color **math**, not the panel, which can't show
wide gamut). Added an "Insta Logo" PhotosCrop demo entry using the
existing Display-P3 `Asset.instaLogo` sample.

## Verification
- BrightroomUI + SwiftUIDemo build clean; **137 engine tests pass** (135
prior + 2 new).
- **On real P3 hardware:** P3 now renders correctly in the editing
canvas and matches export (confirmed via the PhotosCrop "Insta Logo"
sample).

## Not included (follow-up)
- **EDR/HDR:** float drawable + `wantsExtendedDynamicRangeContent` +
per-frame `currentEDRHeadroom` tone-mapping, plus HDR gain-map ingest.
The float intermediates + extended-linear working space introduced here
make that mostly a layer-config + one view-node addition.

## Note
- The `.bgr10a2Unorm` drawable has only 2-bit alpha; verified on-device
that transparent-edge quality is fine. Flipping the single
`drawablePixelFormat` constant to `.rgba16Float` (full alpha, also the
EDR format) is the escape hatch if it ever matters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

Migrates the `BrightroomEngineTests` suite from **XCTest** to the
**Swift Testing** framework (`import Testing`). 27 test files are
converted; one performance file is intentionally left on XCTest (see
below).

`XCTestCase` classes become `struct` / `final class` suites, test
methods gain `@Test` with sentence-case raw identifiers, and assertions
move to `#expect` / `#require`.

## Required project change

The `BrightroomEngineTests` target was typed as
`com.apple.product-type.bundle.**ui-testing**` — a hostless logic-test
bundle that had been mistyped. Swift Testing is **not available in
UI-testing bundles** (`import Testing` fails with `Unable to resolve
module dependency: '_Testing_Unavailable'`), so the product type is
changed to `com.apple.product-type.bundle.**unit-test**`. No host app /
`TEST_HOST` is involved, so this is a no-op for how the logic tests run.

## Notable conversions

- **`expectation` / `wait`** (`LoadingTests`) →
`withCheckedContinuation` (kept `@MainActor` for the StateGraph
observation).
- **`XCTSkip`** for a missing Metal device → `@Test(.enabled(if:
MTLCreateSystemDefaultDevice() != nil))` + `#require`.
- **`XCTAttachment(image:)`** → `Attachment.record(uiImage.pngData()!,
named:)`.
- **`file:`/`line:` forwarding helper** → `sourceLocation:
SourceLocation = SourceLocation(fileID:filePath:line:column:)` default
arg (no underscored API).
- **`continueAfterFailure = false`** → `try #require` throughout that
suite.
- **`tearDown` cleanup** → `deinit` (`DiskExportTests` kept as `final
class`).
- **`XCTAssertThrowsError`** → `#expect(throws:)`, capturing the value
where the error is inspected.

## Kept on XCTest (deliberate)

`EnginePerformanceWorkloadTests` uses `measure(metrics:options:)` with
`XCTClockMetric` / `XCTCPUMetric` as CPU-instruction regression guards.
Swift Testing has no performance-measurement equivalent, so this file
stays on XCTest. A single test bundle runs both frameworks.

## Verification

- `xcodebuild ... build-for-testing` — **succeeds, 0 errors**.
- `xcodebuild ... test-without-building` — **all 134 tests in 31 suites
pass** (mixed XCTest + Swift Testing), including the rebased wide-gamut
`EditingCanvasColorContractTests`.

Rebased on top of the latest `v5` (wide-gamut PR #298); the new
`EditingCanvasColorContractTests` added there is migrated here as well.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pth (#300)

## What

Fixes two visible defects in blur-mask painting (PhotosCrop), plus a
related export-path safety change.

### 1. See-through — the unblurred image bled through the blur
The brush default `opacity` was **0.9**, and the mask accumulates with
**max** (live = Metal `.max` blend; export =
`CIBlendKernel.componentMax`). So opacity acts as a *hard ceiling* —
overlapping/repeated strokes can never exceed the single-stamp peak.
`CIBlendWithAlphaMask` then composited `blur·0.9 + original·0.1`,
leaving ~10% of the sharp original visible even at the stroke core.

**Fix:** default `opacity` 0.9 → **1.0** (`hardness` 0.72 still feathers
the edge).

The color-space / premultiplied-alpha theories were investigated and
**ruled out**: the mask, base, and adjusted images are all sRGB
(`EditingCanvasImageProcessing.colorSpace`), and `CIBlendWithAlphaMask`
blends using only the alpha channel, which is color-space independent.

### 2. Visible stamp seams along a stroke
Default `spacing` 0.18 placed stamps `0.36·radius` apart — wider than
the hardness‑0.72 falloff ring of `(1−0.72)·radius = 0.28·radius` — so
the feathered edge scalloped between stamps.

**Fix:** default `spacing` 0.18 → **0.05** (`~0.10·radius`, about ⅓ of
the ring). Preview and export share the **same stored stamp list**
(commit performs no decimation; export rasterizes stored stamps 1:1), so
this densifies both — as requested.

### 3. Export safety — bound the mask Core Image graph depth
`FeatureGraphCompiler.render(_:BrushMask)` folded each stamp into the
mask with `componentMax` **linearly**, making the CI graph depth O(n
stamps). Denser stamps would risk slow compiles / stack overflow on long
strokes.

**Fix:** reduce stamps in a **balanced tree** (`reduceComponentMax`,
O(log n) depth). `componentMax` is associative and commutative and
`componentMax(transparent, x) == x`, so the result is identical.

## Why
Direct response to the report: blur mask compositing looked wrong
(original faintly showing through) and stamp joints were visible. Both
trace to brush defaults; the export reducer change keeps the denser
default safe.

## Reviewer notes
- Behavior change is limited to **new** strokes' defaults; existing
committed strokes render from their stored stamps unchanged.
- `EditingCanvasStrokeRecord.init(brushMaskStroke:)` spacing is a
display-only placeholder (`BrushMaskStroke` doesn't persist spacing);
updated to match the authoring default.
- Base branch is **v5** (this branch sits on the v5 line, not `main`).

## Verification
- `BrightroomUI` builds (iOS Simulator).
- Tests pass: `BrushMaskRasterizerParityTests`,
`CommittedMaskParametricParityTests`, `LargeMaskedExportTests`,
`LocalAdjustmentRenderingTests` (the balanced-tree reduction preserves
mask parity).
- `opacity 1.0` / `spacing 0.05` are values best confirmed visually;
`spacing` can be lowered further if seams persist (export cost is now
O(log n)).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esult screen (#301)

## Summary

Adds a reusable **FeatureTree viewer** to `BrightroomParametric` (as a
debug component) and uses it on the SwiftUI Demo's **PhotosCrop export
result screen** to show the parametric `EditingDocument` that produced
the render.

The viewer is expressed with `List` + `DisclosureGroup`, as requested.

## BrightroomParametric

New `Sources/BrightroomParametric/Debug/FeatureTreeView.swift`, guarded
by `#if canImport(SwiftUI)` so the otherwise SwiftUI-free module keeps
building.

- `FeatureTreeView(document:)` — standalone `List`-based screen.
- `FeatureTreeOutline(document:)` — embeddable rows for an existing
`List`/`Form`/`Section` (used by the demo).
- `FeatureTreeDescriptor` / `FeatureTreeNode` — the tree builder and
display model (reusable for tests/other hosts).

Design:
- Structural containers (`EffectPipelineFeature`, `PresetFeature`,
`LocalAdjustmentFeature`, `MaskTree`/`MaskNode`) expand into child nodes
explicitly.
- Scalar parameters are read via `Mirror` reflection, so **new leaf
features show up automatically** without touching the viewer. `CGRect` /
`ParametricRGBAColor` / `GaussianBlurRadius` / `QuarterTurn` etc. get
dedicated formatting.
- Each node shows an icon, title, enabled state, a one-line summary, and
(when expanded) `name`(left)/monospaced `value`(right) parameter rows
plus child nodes. Top level is expanded by default; nested structure
stays collapsed.

## Demo

- `RenderedResultView` is now a `List` with a "Result" section (image +
metadata) and a "Feature Tree" section (`FeatureTreeOutline`).
- `DemoPhotosCropView` snapshots
`stack.loadedState?.currentEdit.document` on done and passes it to the
result screen.

## Verification

Built `SwiftUIDemo` (Xcode 27) and verified on iPhone 17 Pro Simulator:
opened PhotosCrop, applied the *Vivid* preset + blur brush strokes,
tapped Done, and confirmed the result screen shows the Feature Tree:

- **Effects** (`1 effect`) → child **Preset (Vivid)**
- **Local Adjustment** (`alpha`) → **Mask → Brush** (Strokes 3 / Stamps
61)
- **Crop** (`Crop Rect (…) · Rotation 0°`) with parameter rows

DisclosureGroup expand/collapse, icons, layout, and long-ID wrapping all
render correctly. No visual bugs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

Fixes PhotosCrop rendering as a black canvas on iOS Simulator while
keeping the 10-bit Display-P3 drawable path on real devices.

## Root Cause

The editing canvas drawable pixel format was changed to `.bgr10a2Unorm`
for the wide-gamut Display-P3 canvas path. That works on device, but
recent iOS Simulator runtimes can create the MTKView and still present
the drawable as black.

## Changes

- Use `.bgra8Unorm` for the editing canvas drawable on Simulator builds.
- Keep `.bgr10a2Unorm` for device builds.
- Add a regression test that locks the intended platform-specific
drawable format while preserving the existing wide-gamut color math
tests.

## Verification

- Reproduced the black PhotosCrop canvas in SwiftUIDemo on iPhone 17
Pro, iOS 26.5 Simulator before the fix.
- Verified the same PhotosCrop Horizontal screen renders the image after
the fix.
- Ran `BrightroomEngineTests/EditingCanvasColorContractTests`: 3 passed.
- Built and launched `SwiftUIDemo` successfully on the same Simulator.
## Summary

- Move parametric brush Core Image kernels from copied `.metal.txt`
source to build-compiled `.metal` sources.
- Move the live brush-mask render shader into BrightroomParametric so it
is compiled into the same `default.metallib`.
- Share brush falloff through `BrushStampFalloff.metalh`, loaded by both
the Core Image kernel and live Metal render shader.
- Load kernels via `CIColorKernel(functionName:fromMetalLibraryData:)`
and live render functions via `MTLDevice.makeLibrary(URL:)`.

## Why

The previous resource-copy/runtime-compile setup existed to avoid
missing `brushStampAlpha` during Metal compilation. Now the shared
falloff is a real Metal header included by both compiled sources, so the
package can ship compiled Metal instead of text resources.

## Validation

- `BrightroomUI` simulator build via XcodeBuildMCP: succeeded.
- `BrightroomEngineTests` targeted tests via XcodeBuildMCP: 4 passed.
  - `BrushStampFalloffTests`
  - `BrushMaskRasterizerParityTests`
- Confirmed build output contains
`Brightroom_BrightroomParametric.bundle/default.metallib` and build log
runs `CompileMetalFile` + `MetalLink` for the new `.metal` sources.
## Summary

- Add a finite maximum crop zoom based on a minimum authored crop-output
side length.
- Clamp programmatic crop zoom to the scroll view's configured zoom
range before calculating content offset.
- Add focused tests for the crop zoom policy on large and small images.

## Root Cause

Crop editing allowed an unbounded zoom scale, so users could author a
final crop output that was only a few pixels wide or tall. Blur masking
uses that final crop output as the tool canvas; once the crop output
became extremely small, the tool surface entered huge zoom-scale space
and viewport-sized brushes resolved into sub-pixel image-space strokes.

## Validation

- `BrightroomEngineTests/CropEditingZoomScaleTests` via XcodeBuildMCP: 2
tests passed
- `git diff --check`
## What

Each filter in the PhotosCrop **Filters** strip now renders a live
thumbnail of the photo with that filter applied
(Photos/Instagram-style), replacing the text-only pills — including an
"Original" swatch.

## How

- **New `PhotosCropFilterThumbnail.swift`** — a `@concurrent`
single-swatch renderer on a dedicated wide-gamut `CIContext` whose
working/output color spaces match the editing canvas
(`extendedLinearDisplayP3` → `displayP3`), so a swatch and the live
canvas resolve the same colors. It center-square-crops the session
thumbnail, never upscales past the source, and threads a source-relative
`radiusReferenceExtent` so host-supplied blur/sharpen presets preview at
the correct strength.
- **Per-cell lazy rendering** — each `PhotosCropFilterChip` renders its
own swatch in a `.task` into its `@State`. A `LazyHStack` defers
off-screen first renders and retains created chips, so scrolling never
re-renders. No shared cache or stringly-typed dictionary.
- **Selected chip auto-centers** via `.scrollPosition(id:anchor:)` on
selection change (clamps at the ends, so Original / the last filter rest
at the edge).
- **Selection ring** uses a dark shadow halo so it stays legible on
bright swatches (Original / Mono / Noir on light photos).
- Swatches preview the **filter only** — not the user's manual Adjust
edits, blur mask, or current crop — matching the Photos/Instagram strip.

## Notes

- Uses the iOS 17 `scrollPosition(id:anchor:)` modifier (not
deprecated). The iOS 18 `ScrollPosition` struct would need `if
#available` since the deployment floor is iOS 17.

## Verification

- BrightroomUI builds clean in Swift 6 language mode.
- Verified on the iOS Simulator: all swatches render distinctly
(Mono/Noir desaturated, Warm/Cool shifted, Fade washed, Vivid/Dramatic
as expected), off-screen chips render lazily when scrolled into view,
the selection ring is visible on bright swatches, and selecting a chip
scrolls it to center.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

`EditingCanvasMode.preview` was a dead compatibility alias of
`.renderedEditPreview`. This removes it and collapses its `switch` arms
into `.renderedEditPreview`.

## Why

An audit of all four `EditingCanvasMode` cases found that `.preview`:

- Was documented in-source as a *"Compatibility spelling for
`renderedEditPreview`"* and was introduced in the **same** commit as
`.renderedEditPreview` (it is not an older, migrated-away-from name).
- Is **never constructed anywhere** — production, demo, or tests. `git
grep` for any producer (`= .preview`, `return .preview`, `mode:
.preview`, `EditingCanvasMode.preview`) returns nothing.
- Appears **only** as a passive `switch` co-arm, always paired with
`.renderedEditPreview` and handled identically, in every `switch` over
the enum:
- `EditingCanvasPublicTypes.swift` — `localEffect`, `activeLocalEffect`,
`defaultInteractionMode`
- `EditingCanvasRenderImageFactory.swift` — `makeRenderImages`,
`makeCropOutputRenderImages`

So the case carried no distinct behavior and could never be reached.

## Behavioral impact

None. No first-party code path ever produced `.preview`, and it was
rendered byte-for-byte identically to `.renderedEditPreview`. The other
three cases (`.viewportBase`, `.localAdjustment`,
`.renderedEditPreview`) are unchanged and remain in use.

## API note

This removes a `public` enum case, so it is technically source-breaking
for any external caller that explicitly referenced
`EditingCanvasMode.preview`. There are no such references inside this
repository.

## Verification

- `git grep` confirms zero remaining `.preview` references in `Sources/`
and `Dev/`.
- `xcodebuild -scheme BrightroomUI -destination 'generic/platform=iOS
Simulator'` → **BUILD SUCCEEDED** (Swift 6 mode).

## Follow-up (not in this PR)

`.renderedEditPreview` itself is only selected by
`CropView.CanvasRenderPlan` when 2+ local-adjustment layers are
simultaneously active — a state the built-in PhotosCrop UI cannot
currently produce. It (and `CanvasRenderPlan`, already marked `// TODO:
Consider to remove this`) is a candidate for removal/replacement once
the live canvas composites multiple local-adjustment layers; left out of
this PR to keep the change a pure, behavior-preserving cleanup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem

In Crop mode, holding a pinch still (fingers down, no movement) would
snap the scroll view back to the recorded crop after ~0.8s, making it
look like the crop was committed mid-gesture.

## Cause

`cropSurface.onDidZoom` schedules `updateCropLayout()` on a trailing
`debounce` (interval 0.8s). `updateCropLayout()` →
`updateScrollContainerView()` → `customZoom(to: crop.zoomExtent())`
resets the scroll view to `state.proposedCrop`, which is **not** updated
mid-pinch (`record()` only runs on settle).

While the user moves their fingers, continuous `scrollViewDidZoom`
events keep resetting the 0.8s debounce. The moment the user **holds
still with fingers down**, events stop firing and the debounce fires
mid-gesture, snapping the view.

The sibling `onDidScroll` debounce already guards against this for drags
with an `isTracking == false` check, but `onDidZoom` had no equivalent
guard — and both closures share the same `debounce` instance, so whether
the snap happens depends on which event was scheduled last.

## Fix

Guard the `onDidZoom` settle layout with
`cropSurface.isInteractiveZoomGestureActive == false` (pinch recognizer
not in `.began`/`.changed`), deferring the reflow until the pinch
actually ends.

The crop **recording** path (`record()` via the
`scrollViewSettleDebounce` → `didSettleScrollViewAdjustment`) already
checks `isContentOffsetResting` (`isZooming == false`, etc.), so commit
semantics are unchanged — this only stops the premature layout snap.

## Verification

- `BrightroomUI` builds (iPhone 17 Pro, iOS 26.5).
- Confirmed in SwiftUIDemo PhotosCrop: pinching and holding still no
longer auto-snaps the crop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The crop surface shows its pixels on a Metal canvas that is repositioned
each frame to follow the transparent zooming platter, rather than being
zoomed by the scroll view directly, so the bounce-back animation depends
on per-frame following of the scroll view's layers. Two issues broke it:

- makeCropDisplayViewport read model-layer values whenever the zoom was
  "interaction active", lumping the active pinch together with the
  post-release bounce-back. During the bounce-back UIKit animates the
  presentation layer while the model has already jumped to the clamped
  value, so model reads snapped the canvas. Read presentation layers during
  the bounce-back (new isInteractiveZoomDriving) and keep model reads only
  while the pinch actively drives the zoom.

- updateCurrentEditingStackDisplay's document-follow guard checked isZooming
  but not isZoomBouncing/isDecelerating, so a SwiftUI re-render during the
  bounce could re-load the document crop and customZoom(animated: false)
  back, cancelling the bounce. Treat bounce/decelerate as live interaction,
  matching isContentOffsetResting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
muukii and others added 5 commits June 18, 2026 02:21
…ent bake (#308)

## Summary

Two performance fixes for slow CropView rotation, found and verified
with on-device Instruments Time Profiler traces. Both are behind
pixel-equivalence tests (the editing canvas renders black on the
Simulator, so correctness is guarded via CIContext read-back, and the
felt win was confirmed on device).

### 1. Make the editing source GPU-resident (adaptive bit depth)

The editing source was a CPU-backed `CIImage(cgImage:)`, so Core Image
re-uploaded its bitmap CPU→GPU on **every** canvas render. During
rotation the viewport cache invalidates every frame, so the trace showed
`CIMetalTextureSetBytes` / `replaceRegion:withBytes:` consuming ~40% of
all main-thread time.

`EditingSourcePreparation.makeGPUResidentSource` uploads the oriented
source into a persistent private `MTLTexture` once at load (background
queue) and returns a texture-backed `CIImage`. The texture depth
**adapts to the source**: `rgba8Unorm` for an 8-bit source (the common
case — lossless, half the memory), `rgba16Float` for >8-bit / HDR.

This re-does what #297 removed, but avoids that PR's downsides
(`MTKTextureLoader` 8-bit quantization / no 16-bit / y-flip) by
rendering through a `CIContext` in the source's own color space — the
`CIContext.render(to:)` / `CIImage(mtlTexture:)` round-trip is
flip-free. `EditingImageWarmUp` is deleted (building the texture warms
the pipeline).

### 2. Bake the local-effect layer once per generation

With the upload gone, the trace's remaining per-frame cost was Core
Image re-evaluating the blur-heavy `adjusted` graph every frame
(`create_intermediate` / `GetSurfaceFromCache` — IOSurface allocation
under a barrier lock). The effect content is invariant during a gesture;
only the viewport moves.

`EditingCanvasContentBake.bake` renders the `adjusted` layer into a
capped (2560) texture once per render-images generation; the composite
path resamples it per frame. The cache is invalidated only on
`setRenderImages` / `setViewportCachedSourceEnabled`, never on viewport
changes. `base` (global pointwise effects on the now-GPU-resident
source) stays a live graph — cheap to re-evaluate, and not baking it
halves the held texture memory.

## Measured (device, normalized ms per wall-second)

| Main-thread cost | before | after |
| --- | --- | --- |
| Canvas draw (`MTKView.draw`) | 102 | 26 |
| CI graph eval (`tile_node_graph`) | 70 | 3 |
| IOSurface alloc + barrier lock | 58 | 0 |
| Per-frame source upload | (eliminated in step 1) | 0 |

`CA::Transaction::commit` dropped 158 → 90. The new dominant rotation
cost is UIKit layout (`updateUIViewController` → `updateCropLayout`),
tracked as a follow-up.

## Memory

Adaptive source depth + baking only `adjusted` keep the footprint
bounded. For an 8-bit source: plain crop/rotate holds just the ~26MB
source texture (≈ the pre-change CGImage footprint); a blur-mask gesture
adds one ~52MB `adjusted` bake. The `adjusted` bake stays `rgba16Float`
deliberately — it is the extended-linear-Display-P3 intermediate, where
8-bit linear bands and can't carry exposure overshoot / wide gamut.

## Test plan

- [x] `xcodebuild -scheme BrightroomUI` builds.
- [x] `EditingSourceTextureTests` — texture-backed source is
pixel-equivalent to `CIImage(cgImage:).oriented()` across 8 orientations
× {8,16}-bit (16 cases).
- [x] `EditingCanvasContentBakeTests` — bake is a faithful drop-in
(offset extent, asymmetric content, within float tolerance; cap
respected).
- [x] Regression-green: ColorContract, CropSurfaceBlurRenderPath,
CommittedMaskParametricParity, LocalAdjustmentRendering/MaskOrientation,
EditingPreviewExportParity, MaskedPreviewExportScaleConsistency,
RendererOrientation.
- [ ] Device re-trace to confirm keeping `base` un-baked did not
re-introduce per-frame cost (expected fine — pointwise on a GPU
texture).

## Follow-ups (docs/editing-engine-todo.md item 7)

- UIKit per-frame layout during the angle drag is now the dominant
rotation cost.
- Remaining bake-memory levers (resolution cap, confirmed-SDR formats)
if needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- Make the crop Metal canvas follow UIScrollView zoom bounce-back by
sampling presentation-layer geometry after the pinch ends.
- Apply the same presentation-layer viewport tracking to the Tool/Mask
surface so the mask canvas follows min-zoom shrink/bounce without
drifting toward the top-left.
- Defer document-crop reload while scroll/zoom settling is still active,
avoiding a non-animated `customZoom` snap during bounce-back.

## Root Cause
The viewport display link was already running, but
`makeCropDisplayViewport()` disabled presentation-layer reads for the
full zoom interaction, including the post-release bounce. During
bounce-back UIKit animates the presentation layer while model values are
already clamped, so the canvas snapped instead of following the visible
image. Tool/Mask mode also computed its viewport from model
`contentOffset`/`contentInset`/`zoomScale`, which lost the
presentation-time centering during min-zoom shrink.

## Validation
- `git diff --check`
- Xcode 27 `SwiftUIDemo` launched on `iPhone 17 Pro (27.0)` simulator
from the cwd worktree; build completed with no errors. Launch session:
`77a4d4780`.
## Summary

Fixes a regression where, after changing the zoom in **Crop** mode and
switching to **Blur** (tool/mask) mode, the crop output rendered **small
and pinned to the top-left**. Toggling the Crop/Blur tabs again
corrected it; zooming the other way produced the inverse misplacement.

## Root cause

`makeToolCropDisplayViewport` derives the Metal canvas placement from
**presentation-layer** conversions (`currentLayerRect(...
usesPresentationLayers:)`), gated only on
`toolSurface.isInteractiveZoomGestureActive == false`. This was
introduced in #309 to make the mask canvas follow the post-release zoom
bounce.

The Crop→Blur switch applies the tool viewport **once, synchronously**,
in the same runloop turn that `updateToolScrollGeometry` just
reconfigured the freshly-un-hidden tool scroll view (non-animated
`setZoomScale`, `centerContentInViewport`, etc.):

`setFeatureFocus` → `applySurfaceMode(syncsToolViewportFromCrop: true)`
→ `updateToolScrollGeometry` (model set synchronously) →
`updateToolCropDisplayViewport()` (one-shot, no display link).

At that instant no pinch is active, so `usesPresentationLayers == true`,
but Core Animation has not yet committed the new geometry to the
presentation layers — they still hold the **previous** Blur session's
geometry. The one-shot samples that stale transform → small + top-left
placement, and because no display link runs afterward, the stale frame
sticks until the next mode switch (the "switch back and forth fixes it"
symptom).

`#308` (GPU-resident source / per-generation content bake), originally
suspected, is **not** involved: it never touches `CropView.swift`, and
`EditingCanvasContentBake` maps the bake back to the original `extent`,
so it changes layer *content*, never viewport *placement*.

## Fix

Presentation-layer reads are only meaningful while the viewport display
link is actively re-sampling an in-flight bounce. Gate them on whether
that link is running:

```swift
let usesPresentationLayers = toolSurface.viewportRendering.isRunning
  && toolSurface.isInteractiveZoomGestureActive == false
```

This flips **only** the link-idle one-shot paths (i.e. the mode switch)
back to the model layers, which the synchronous reconfigure already made
authoritative. Every display-link-driven path (interactive pinch,
post-release bounce, deceleration) is unchanged, so #309's bounce
tracking still works.

## Scope / notes

- Scoped to the **tool** path only. `makeCropDisplayViewport` has the
same presentation-read pattern, but the crop path is not a reported
repro and its animator-driven rotation computes the viewport inside a
`UIViewPropertyAnimator` block (where presentation reads are intended),
so it is intentionally left untouched.
- The tool path's animator case early-returns (the tool surface is
hidden in Crop mode), so the gate is safe there.

## Test plan

- [x] `xcodebuild -scheme BrightroomUI -destination 'platform=iOS
Simulator,name=iPhone 17 Pro'` builds.
- [x] Verified on device: Crop zoom change → switch to Blur now displays
the crop output correctly (no small/top-left misplacement, no need to
toggle tabs).
- [ ] Editing canvas renders black on the Simulator, so visual
confirmation is device-only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- Add Blur to the PhotosCrop Adjust parameter list.
- Write Blur edits into the global-effects node as
`GaussianBlurFeature(value:)`.
- Add focused tests for blur insertion, neutral removal, and PhotosCrop
effect ordering.

## Why
`GaussianBlurFeature` already existed in the parametric layer and
PhotosCrop effect ordering, but PhotosCrop's Adjust UI did not expose a
global Blur parameter. This makes Gaussian blur available as a
first-class Feature adjustment in that UI.

## Validation
- BrightroomUI simulator build succeeded via XcodeBuildMCP.
- `BrightroomEngineTests/PhotosCropAdjustmentParameterTests` passed: 3
tests.
## Summary

- Introduce `PhotosCropEditingModel` as the PhotosCrop policy layer over
the generic FeatureTree document.
- Add `CropViewDocument` so CropView edits through a document boundary
instead of depending on `EditingStack` directly.
- Move undo/redo checkpoint storage behind `EditingHistory` and expose
clearer stack APIs: `commitCurrentEditIfNeeded`, `undo`, `redo`,
`revertCurrentEdit`, and `removeAllHistory`.
- Remove legacy crop-specific stack helpers and update demos, docs, and
render tests to use FeatureTree-oriented mutation paths.

## Validation

- `BrightroomEngineTests`: 152 passed on iPhone 17 simulator.
- `BrightroomUI` simulator build: succeeded.
- `SwiftUIDemo` simulator build: succeeded.
- `git diff --check`: passed.

Note: `SwiftUIDemo` still reports existing iOS 17
`onChange(of:perform:)` deprecation warnings unrelated to this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant