A custom white balance on a raw with a colour matrix (DNGs, and other raws
without a file-local look) re-develops the pixels for the chosen white
through the file's own colour model: lightcraft_raw::color::rebalance
re-evaluates the camera transform at the new white and maps the as-shot
development onto it, so a neutral lit by that white renders neutral and
saturated colours move as the camera's matrices say (DNG spec ch. 6, as
Lightroom does). The decoder passes the colour tags and the white the
pixels were developed for in SourceInfo::camera_color; other photos, and
relative white balances, keep the Bradford adaptation. The eyedropper and
Auto invert the same model (color::neutral_white), so a picked grey card
renders neutral.
The Tint sign is main's (#349): unchanged here. Render cache version 15.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read DNG 1.6 semantic masks (SemanticName, SemanticInstanceID,
MaskSubArea; any coding the tile/strip reader decodes, e.g. ProRAW's
lossy-JPEG matte) with lightcraft_raw::semantic_masks, map them onto the
developed image, and carry Apple's sky / portrait / skin / hair / teeth /
glasses mattes in SourceInfo::mattes (SourceInfo is no longer Copy).
Sky, Subject, Background and People components sample them through
orientation, warp (Warp::frame, as geometry.rs) and crop like the image,
so preview = export, instead of the heuristics; files without mattes
render as before. Malformed mask IFDs are skipped, and only the first
MAX_MASKS mask IFDs are decoded, so a file full of them costs bounded
work. The render cache version is bumped once, in the white-balance commit.
The corpus test reads corpus/raw/dng-apple-iphone12pro-proraw.dng, the
name `cargo xtask corpus` downloads it under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings: window growth for spots was capped silently and then rendered
wrongly; Auto Mask growth took the whole stroke's bounding box; hidden masks grew
windows; the wasm window memory cap was not enforced. window_for_reads_checked
returns None when what a window reads would need more than 8192 px (4096 on
wasm) along an axis, Session::region_job returns None then (the loupe keeps the
whole-frame render), Auto Mask growth is per dab near the request (once, not
chained along the stroke) and ignores hidden masks. Docs say what the limits are.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The GPU renderer renders windows (no more CPU fallback): the vignette gets the
window's offset and the whole output's size (VIG_VIEW), the airlight and noise
reduction scale come from the whole frame, and a window grown for spots or
strokes is cut back after the readback. equivalence.rs renders every settings
case as a window plus a corner vignette and a spot reading from outside, within
the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read
the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its
position at f32 precision, like the GPU kernel: an f64 rounding error that
depended on the window's start could put a boundary dab on either pixel.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spot removal reads outside the spot and its automatic source depends on the
pixels at hand. A windowed render now fixes automatic sources from a fixed-size
render of the whole frame and grows the window it works on to hold every spot
that touches the requested one and the patch it reads (bounded at 8192 px),
cutting the result back to the request, so photos with spots get sharp windows
too. The wide stages (dehaze, clarity, highlights/shadows) are checked at depth
on hard-edged blocks: the cut kernels cost at most a few levels, so they are not
computed from a whole-frame proxy.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An adversarial review found local::denoise and Refine Edges scaled with the
window's own size, so their strength changed with pan and zoom. Frame::output_long
gives the whole output's long edge to both; tests on a noisy source cover them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame
texture stretched over them. RenderRequest gains an optional PixelWindow of the
virtual full output that max_w x max_h describes; only the window is
materialized. It is Frame:🪟 the crop rectangle narrows to the window
(flips as drawn), so sampling, masks, spots, red eye and the lens warp need no
change. The post-crop vignette keeps the whole frame's geometry (Frame::view)
and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a
window matches the same pixels of the full render. The GPU renderer declines
windowed requests (CPU for now).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The pipeline honoured a section's eye only where it applies Effects,
Optics, Geometry and Calibration, so turning the eye off on Light, Color
or Detail changed nothing. DevelopSettings::effective() resets the
switched-off sections to their defaults (Light with the tone curve; Color
with white balance, mixers, grading and point colour; Detail), and both the
CPU and GPU renderers render those. Borrowed, so free, when every section
is on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
`tol = 0.04 + 0.16·refine` fully selected anything within 0.06 OkLab of a
sample at the default Refine 50. Through `tonemap_for_select` the report's
teal mid-grey and neutral light grey are only ~0.063 apart (L 0.60 vs 0.72,
a/b ≤ 0.02), so the second patch was 99 % selected and the mask covered the
whole photo; Refine 85 and 100 kept most of it too. Now
`0.015 + 0.06·refine` (full ≤ 0.0225 / gone ≥ 0.045 at Refine 50, gone ≥
0.066 at 85, 0.075 at 100), computed once in
`lightcraft_pipeline::masks::color_range_tolerance` and used by both the CPU
evaluator and the GPU `mask.wgsl` kind-3 parameter so the two keep agreeing.
Test: two flat patches at the report's sRGB values, sampled as
`mask.sampleColor` does; the sampled patch stays fully selected and the other
one is out at Refine 0, 50 and 85 and mostly out at 100.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the
camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3,
so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and
"Enable Profile Corrections" applies it like a DNG's warp (no new warp path;
Convert to DNG keeps it). Rule, from our own measurements against the
embedded camera JPEGs (clean-room; ExifTool tag names only):
corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12
(w12 = active-area half diagonal), output = corrected / (1 + w5/32768),
on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos
from 12 other bodies / 9 other lenses; corrected renders line up with the
camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the
half diagonal before). Implausible values leave the photo uncorrected.
The loader now removes lens opcodes before the RW2 camera-look fit (its
binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction()
replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12.
Homography::apply, Affine::apply, Warp::to_corrected,
Warp::corrected_to_source and embedded_warp are now generic over
lightcraft_geom::Real (private *_real variants in pipeline, public
apply_real in geom); the f64 API is a thin wrapper, so callers are
unchanged. Real is implemented for f64 (plain IEEE operators) and for
Interval, which rounds every bound one ulp outward and turns division by
a range containing zero, the homography's 1e-300 denominator clamp and
non-finite bounds into "undecided" (NaN). Because the interval runs the
same operations as the f64 path, it bounds the rounded f64 results, not
only the exact ones.
The f64 path keeps the same operations in the same order (only commuted
operands, which IEEE + and x leave bit-identical; no fma). Proof:
warp_f64_results_are_bit_identical_to_the_pre_generic_formulas hashes the
bits of 2000 random warps x 40 points through every formula (plus the
clamp branch, including +-1e-300 exactly) and matches the digest computed
by the same test on thumb-split (419261c) before the change; reassociating
one expression, or `<=` for `<` in the clamp, changes it.
The framing chain (output px -> transformed -> lens-corrected -> green
source, then inside the edges) is written once: Warp::framing, generic.
Warp::frame (f64, returns the corrected and green source points) serves
both the CPU resample (sample_warped) and Warp::covers; block_coverage
evaluates the same chain with intervals over a whole block of output
pixels and compares the bounds against the same edges() inside uses, with
no margin: the bounds enclose every pixel's f64 result, so the decision
equals the per-pixel one; else None.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine (crates/engine/src/segment/):
- One worker thread owns the model and the encoded photo; the session
sends jobs (with the render job for the model input) and gets results
over channels, never locking what the model holds. In the app
(Segmenter::background) clicks, descriptions, prepares and detail
passes return at once and Session::segment_poll applies results (one
undo step per settled selection, pending clicks shown on the photo);
the CLI/MCP wait for them. Newer requests for the same selection
supersede queued ones.
- Every job runs under catch_unwind: a candle panic becomes an error and
the model is reloaded next time; busy counters are reset by drop
guards; a dead worker is restarted. The model unloads after 10 min idle.
- segment.model.status / .download {acknowledged: true} / .cancel: the
download runs on its own thread; status via atomics (try_lock only).
Without the model every AI request fails at once with "The SAM 3 model
is not installed…"; renders/exports never need it; `seg` params make
AI masks from stored segmentations without it.
- Hostile documents: at most 4 detail patches per component (capped on
deserialize and in the renderer), at most 64 Object clicks.
UI: Object / Describe without the model open "Download the SAM 3
Model?" (size, folder, SAM License link; Not Now / Download, progress,
cancel, Try Again with the reason, Continue once installed); progress in
the Masking panel when the dialog is closed. Reverted the PR's unrelated
default change (mask overlay off + hover-red) to main's behaviour.
tools/install-sam3.*: --fail so error pages never land in files, 416
restarts instead of looping, the weights are verified before they are
moved into place (a bad file is deleted, never resumed from).
Docs: docs/ai-masks.md (in-app download, mirrors, SAM License summary,
no weights in the repo), NOTICE, parity LR-MASK-OBJECTS, What's New,
ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sunny ILCE-7M4 waterfall shot rendered yellow: sunlit rock came out
cream, +4 b* in the brightest fifth of the picture. Camera JPEGs make
shadows up to 1.4x more colourful and bleach highlights to 0.1-0.3x
(a per-channel tone curve), which a luminance-only curve can't do.
- CameraTone gains an 8-node chroma-by-display-luminance curve, fitted
per file (bright pixels included) and kept only when it lowers the
held-out error; applied after the tone map on CPU and GPU, keeping
luminance. Old smart-preview headers load with an identity curve.
- The hue/saturation table gets an sRGB-encoded value axis: dark yellows
had to turn toward orange while bright yellow-greens turned toward
green. It is applied after the baseline exposure, as it is fitted.
- Matrix-only and matrix+table looks are completed (tone, chroma) and
compared in gamma-encoded values; acceptance gates stay linear.
29 local ARWs vs their camera JPEGs: mean dE 4.23 -> 3.54, every file
closer, saturated areas 7.65 -> 6.41. Render cache v10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.
- Object: click to include, ⌥-click to leave a part out (SAM 3's
interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
"car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
area around a selection from a sharper source: 5-10x finer edges on
small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
app (LIGHTCRAFT_LOG=info).
candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
- color: RgbSpace::to_xyz/from_xyz and bradford() no longer expect a
matrix inverse. Degenerate (collinear) primaries, which readers reject
before building a space from a file, leave the primaries unscaled /
give the identity; the constant Bradford and OKLab matrices fall back
to the identity, which can't happen (round trips are tested).
- pipeline geometry: sample() matches (mode, warp) instead of
expect("warp"); a warp plan without a warp (sample_plan never makes
one) samples through the equivalent affine.
- color, geom, raster, develop, scenes, preview and pipeline now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in
the steering PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust 1.99 (stable since 2026-09-28) adds clippy::chunks_exact_to_as_chunks and widens
clippy::question_mark, which failed `cargo xtask ci` on main. Same behaviour: the
remainder that chunks_exact skipped is the remainder as_chunks returns separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack):
- pipeline Proof {space, destWarning, displayWarning}: gamut-map into the
proof space, show it in the output space; red/blue gamut warnings
(neutral clipping excluded). CPU only; the GPU path declines proofs.
- RenderJob::with_proof; paper-white surround and Proof Preview label;
Soft Proofing strip under the histogram (profile, warnings, Create
Proof Copy = a named virtual copy). view.softProof for control/MCP.
- photo.virtualCopy takes an optional name.
Mask components: MaskComponent.name; mask.component {component, action:
invert|duplicate|delete|rename|op}; right-click / … menu per component,
double-click rename, Intersect button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are
combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long
edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no
kernel for it yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline dust.rs: sensor-dust detection on a rendered image — pixels darker than
their blurred surroundings by more than 3× the local texture, grouped into small (0.15–2 % of
the long edge), roughly round blobs. spot.findDust {sensitivity?, add?} renders the uncropped
photo, detects and adds a heal spot on each (auto source; one undo step). Remove panel ▸
Find Dust Spots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline cull.rs: focus (Laplacian energy of the sharpest 16-px blocks at 512 px,
0–100, calibrated on the CC0 corpus), clipping share and an 8×8 look-alike signature.
photo.analyze scores the selection (else the view), groups look-alike shots taken within 10 s
into bursts and marks each burst's sharpest; optionally rejects photos below a focus score and
picks each burst's best (one undo step). Scores live on the photo (Analysis, Op::SetAnalysis):
rule fields Focus and Best of Similar Shots, Info panel line. Photo ▸ Assisted Culling….
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When only vibrance / saturation (and colour grading) are active, chroma is scaled on OkLab
a/b directly — the same result without sin/cos (and atan2 unless vibrance needs the hue).
Bench (loaded machine): finish 1920×1280 + vibrance/saturation 71.8 → 47.7 ms wall on the CPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lightcraft-pipeline gains lut.rs: a .cube parser (LUT_3D_SIZE, DOMAIN_MIN/MAX, TITLE), a
trilinear lookup and an in-process registry; the finishing stage applies a `lut:` profile to
the display-encoded colour, blended by the profile amount (0–200 %); LUT profiles render on the
CPU. profile.import {paths} reads files, folders and zips, copies each LUT into the library's
Profiles/ folder, lists them in prefs and registers them when the library opens; the profile
browser shows them under their folder / zip name; profile.deleteImported. File ▸ Import
Profiles & Presets…, the file picker and drag & drop take .cube files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Color mask tile adds an empty colour range and arms the picker: click the photo to sample
(⇧-click adds, up to five); Pick in the Masking panel re-arms it. mask.sampleColor {x, y, add?}
samples via lightcraft_pipeline::color_range_sample, which reads the exposed scene colour at
that point in the same OkLab space the mask compares in (so a click selects what was clicked).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- develop.autoBwMix (B&W Mixer → Auto): each hue band's colourful pixels are pushed away from
the image's mean lightness (light bands brighter, dark ones darker; bands with almost no
colour untouched), clamped to ±60; switches to B&W; one undo step.
- library.filter {merged: hdr | panorama | hdrPanorama | any}: Photo Merge results recognised
by the "-HDR" / "-Pano" / "-HDR-Pano" names merges write; filter bar type picker lists HDR,
Panoramas, HDR Panoramas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Resize modes: long edge, short edge, width, height, W x H (either orientation), megapixels,
percent; "don't enlarge" (default on); ppi written to JFIF density / PNG pHYs / TIFF tags.
- Full-size export of a cropped photo now renders the crop at native resolution (it was
upscaled to the uncropped long edge).
- Formats: Original (file copied byte for byte + XMP sidecar with the edits, named after the
output) and DNG (raw photos re-encoded losslessly, edits in the embedded XMP).
- Export dialog: Resize-to dropdown, values, Don't enlarge, Resolution; DNG/Original hide
pixel options.
- lightcraft-cli render goes through the shared exporter (colour space, metadata, ppi) and
takes any app.export option via --opt KEY=VALUE; MCP export schema lists the new params.
- xtask resolves the workspace root at run time (a moved checkout broke ci).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine: spot.select / spot.update (move target or source, size, feather, opacity, mode) /
spot.refreshSource (next-best source away from the current one) / spot.delete (selected spot);
spot.add resolves the automatic source (so it has a pin and stays put at every size) and selects
the new spot. Remove tool: pins for targets and the selected spot's source, click to select, drag
to move; Size/Feather/Opacity sliders edit new and selected spots; ⌫ deletes the selected spot
(never the photo), / refreshes its source, [ ] size and Shift+[ ] feather (Masking brush too).
Command tests and headless pointer/keyboard tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto Mask strokes weight each dab by similarity (log luminance + chromaticity) to the pixel under
its centre and are refined by a cross guided filter on log luminance (max-combined, never removing
paint). Local Noise smooths small-amplitude luminance detail (negative boosts it) and calms colour
noise; Moiré blends chromaticity towards a blurred plane (appended to the GPU mask buffer);
Defringe desaturates purple/green casts on edges. Pixel tests per slider and for the edge
constraint, CPU/GPU equivalence cases, Auto Mask checkbox and Moiré/Defringe sliders in the panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RenderRequest gains a sample format (OutputDepth: U8 for previews, U16, F32Linear); deep renders
run the per-pixel stage with the exact sRGB curve and return Rendered::deep alongside the 8-bit
image (GPU stays 8-bit: deep exports render on the CPU). Export encodes deep renders: 16-bit PNG and
TIFF, 32-bit float linear TIFF with a linear ICC profile we generate, 10-bit AVIF (YCbCr planes into
ravif). Output sharpening and watermarks work on deep images. ExportOptions.bitDepth with per-format
defaults (TIFF 16-bit, others 8-bit), export dialog bit depth choice (remembered with the last
export), MCP export schema. Tests: a smooth gradient has 1024 levels in a 16-bit TIFF vs 13 in
8-bit, float TIFF decodes to the same light as 16-bit, per-format depth rules, AVIF 10-bit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The selected mask's evaluated alpha is drawn by the renderer as Overlay::Mask (CPU and GPU share the
alpha the render used; hidden masks are evaluated on demand), kept out of the view-preview cache.
Pins for radial/linear/brush components select their mask on click and move the component on drag;
view.maskPins hides them. CPU/GPU equivalence and headless keyboard/pointer tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RenderRequest gains an output space (default sRGB; previews stay sRGB). The per-pixel stage converts
scene-linear Rec.2020 to the target primaries, gamut-maps into the target gamut and encodes with the
target curve, on CPU and GPU (WGSL finish kernel reads the matrix, luma weights and curve from its
parameter block). sRGB output is byte-identical to before on the CPU. Export embeds our own
generated ICC profile for the space; AVIF stays sRGB. ExportOptions.colorSpace, export dialog and
MCP export schema. Tests: P3 colour survives a P3 export (decoded through the embedded profile),
every space/format round-trips its profile, GPU equivalence per space.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix-ups: diagnostic overlays never populate the view-preview cache; Photo Merge's RenderRequest gets
the new overlay field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Overlay::Spots(threshold): luminance minus its Gaussian blur (sigma relative to the long edge,
so the same specks show at any preview size), thresholded to white/black; the threshold slider
(0..100) raises sensitivity. Runs after either renderer like the other overlays (equivalence:
share of differing pixels, since the view is binary). Remove panel: Visualize Spots checkbox +
Threshold slider; UI command view.visualizeSpots (A) opens the Remove tool with the view on or
toggles it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pet eyes are found by brightness (the tapetum glows in any colour) and the disc is replaced by
a dark neutral pupil (Darken scales it); redeye.catchlight {index, on, offset} adds a soft
white catchlight at an offset from the pupil centre in pupil radii. Red Eye panel: Red Eye /
Pet Eye mode for new corrections and an Add Catchlight checkbox. CPU/GPU twins updated
together (equivalence covers both modes and the catchlight).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pipeline::redeye: inside the user's ellipse the pupil is found on the source image (a fixed
grid in normalized coordinates, so previews and exports agree): weighted centroid and area of
red-dominant pixels (or, for pet eyes, of the brightest ones). Pupil Size scales the detected
disc; rendering in scene-linear light after white balance neutralizes the red where it is red
(iris and catchlights stay) and darkens it. CPU (lin stage) and GPU kernel redeye_k with an
equivalence test (both the GPU and the CPU scene-linear paths). Indexed controls
redEye.<i>.pupilSize/darken, redeye.add/delete commands, the Red Eye panel (selected eye's
sliders, delete) and the loupe tool (drag an ellipse, click to select).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto uses Upright's consensus level angle as the crop angle; sign verified on a tilted test image
and pinned by a test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>