diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs
--- a/crates/pipeline/src/geometry.rs
+++ b/crates/pipeline/src/geometry.rs
@@ -77,15 +77,32 @@ impl Frame {
             warp: (!warp.is_identity()).then_some(warp),
             view: None,
         };
-        if apply_crop && s.geometry.constrain_crop {
-            f.constrain_to_image();
+        if apply_crop {
+            f.constrain_to_image(s.geometry.constrain_crop);
         }
         f
     }
 
-    /// "Constrain Crop": shrink the crop about its centre until it contains no area outside the warped source.
-    fn constrain_to_image(&mut self) {
-        let Some(wp) = self.warp.as_ref().filter(|w| w.moves_pixels()) else { return };
+    /// Shrink the crop about its centre until it contains no area outside the warped source. Lens
+    /// corrections (profile, embedded camera warp, manual distortion, lateral CA) always do this, as
+    /// a corrected lens never shows the blank past the photo's edge (issue #2361); perspective only
+    /// with "Constrain Crop" (`with_persp`), so a keystone fix can keep its corners.
+    pub(crate) fn constrain_to_image(&mut self, with_persp: bool) {
+        let Some(full) = self.warp.as_ref().filter(|w| w.moves_pixels()) else { return };
+        let optics;
+        let wp = if with_persp || full.persp == Homography::IDENTITY {
+            full
+        } else {
+            let mut o = full.clone();
+            o.persp = Homography::IDENTITY;
+            o.persp_inv = Homography::IDENTITY;
+            optics = o;
+            &optics
+        };
+        if !wp.moves_pixels() {
+            return;
+        }
+        let planes: &[usize] = if wp.per_channel() { &[0, 1, 2] } else { &[1] };
         let a = self.crop.output_to_source(self.ow, self.oh, 1.0, 1.0);
         let (w, h) = (self.ow, self.oh);
         let fits = |t: f64| {
@@ -93,8 +110,10 @@ impl Frame {
                 let u = i as f64 / 48.0;
                 [(u, 0.0), (u, 1.0), (0.0, u), (1.0, u)].iter().all(|&(x, y)| {
                     let p = Point::new(0.5 + t * (x - 0.5), 0.5 + t * (y - 0.5));
-                    let s = wp.to_source(a.apply(p), 1);
-                    s.x >= -1e-6 * w && s.y >= -1e-6 * h && s.x <= w * (1.0 + 1e-6) && s.y <= h * (1.0 + 1e-6)
+                    planes.iter().all(|&ch| {
+                        let s = wp.to_source(a.apply(p), ch);
+                        s.x >= -1e-6 * w && s.y >= -1e-6 * h && s.x <= w * (1.0 + 1e-6) && s.y <= h * (1.0 + 1e-6)
+                    })
                 })
             })
         };
diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs
--- a/crates/pipeline/src/lib.rs
+++ b/crates/pipeline/src/lib.rs
@@ -236,6 +236,10 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro
     let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref());
     if s.optics.remove_ca && s.section_enabled("optics") {
         frame.add_lateral_ca(optics::estimate_lateral_ca(src));
+        if apply_crop {
+            // The estimated CA moves the colour planes too: keep the crop inside every one of them.
+            frame.constrain_to_image(s.geometry.constrain_crop);
+        }
     }
     frame
 }
diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs
--- a/crates/pipeline/src/tests_geometry.rs
+++ b/crates/pipeline/src/tests_geometry.rs
@@ -260,6 +260,26 @@ fn constrain_crop_removes_blank_areas() {
     assert!(blank_fraction(&c) < 0.002, "constrained: {}", blank_fraction(&c));
 }
 
+/// Issue #2361: lens corrections never show the blank past the photo's edge, with or without
+/// Constrain Crop, in every colour plane; perspective alone still needs Constrain Crop.
+#[test]
+fn lens_corrections_leave_no_blank_corners() {
+    let src = Rgb32f::from_fn(300, 200, |x, y| [0.05 + 0.002 * x as f32, 0.1 + 0.002 * y as f32, 0.3]);
+    for (distortion, ca) in [(-80.0, 0.0), (80.0, 0.0), (-40.0, 100.0), (0.0, -100.0)] {
+        let mut s = DevelopSettings::default();
+        s.optics.distortion = distortion;
+        s.optics.ca_red = ca;
+        s.optics.ca_blue = -ca;
+        let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
+        assert!(blank_fraction(&r) < 0.002, "distortion {distortion}, CA {ca}: {}", blank_fraction(&r));
+    }
+    let mut s = DevelopSettings::default();
+    s.optics.distortion = -60.0;
+    s.geometry.vertical = -60.0;
+    let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
+    assert!(blank_fraction(&r) > 0.03, "the keystone keeps its corners: {}", blank_fraction(&r));
+}
+
 #[test]
 fn transforms_preview_matches_export() {
     let src = lightcraft_scenes::demo_library()[4].render(960, 640);
diff --git a/docs/whats-new.md b/docs/whats-new.md
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,12 @@
 # What's new in LightCraft
 
+## 2026-10-26
+
+### Lens corrections without grey corners
+- Lens profiles, the camera's own distortion correction, manual Distortion and Chromatic Aberration now crop just
+  inside the corrected photo, as in Lightroom, so no grey corners or edge marks show. Perspective corrections still
+  need Constrain Crop to do the same.
+
 ## 2026-10-24
 
 ### Relative-date filters and smart albums follow the clock
