mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
* Crop: Delete Cropped Pixels keeps type and smart objects whole (#7116) crop_doc cropped every surface for_each_surface visits, including the rendered caches of type layers and smart objects. Those move by shifting their stored pixels, so the cut stayed and showed as soon as they were moved. Like Photoshop, Delete Cropped Pixels now deletes pixel-layer data (and fill caches) only; shapes are re-rendered as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: Delete Cropped Pixels crops stored video frames again (#4956) The pixel-layer-only crop path skipped a video layer's stored frames and original footage, so scrubbing brought the cropped pixels back. Crop them with the displayed content, as before, and test a shrinking crop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address CodeRabbit review: the turned crop keeps type and smart objects whole before rotating The pass that drops what a turned crop deletes before the rotation still cut every cached surface. The rotation re-renders type and smart objects, but a smart object whose source is unavailable keeps its cache, so it stayed clipped. That pass now uses the same pixel-layer-only rule as the crop itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
6a36c48670
commit
aaaa990314
@@ -371,6 +371,26 @@ pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete Cropped Pixels on a layer tree: crops pixel-layer data only to `r`. Type and smart
|
||||
/// objects move by shifting their rendered pixels, so cutting those would clip them at the old
|
||||
/// edge. A video layer's stored frames are its pixels too: the displayed content is only the frame
|
||||
/// at the playhead, so the others would bring the cropped pixels back when scrubbing (#4956).
|
||||
fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) {
|
||||
for_each_layer(layers, &mut |l| {
|
||||
if let Some(fc) = &mut l.fill_cache {
|
||||
fc.surface = crop_surface(&fc.surface, r);
|
||||
}
|
||||
if let Some(v) = &mut l.video {
|
||||
for frame in v.frames.iter_mut().chain(v.original_frames.iter_mut().flatten()) {
|
||||
*frame = crop_surface(frame, r);
|
||||
}
|
||||
}
|
||||
if let LayerContent::Raster(s) = &mut l.content {
|
||||
*s = crop_surface(s, r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Crops the document to `r` (in current document coordinates).
|
||||
/// A frame that extends past the canvas fills the Background's new area with `extension` (the
|
||||
/// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there.
|
||||
@@ -382,7 +402,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
||||
let old_canvas = doc.bounds();
|
||||
let extends = r.x0 < old_canvas.x0 || r.y0 < old_canvas.y0 || r.x1 > old_canvas.x1 || r.y1 > old_canvas.y1;
|
||||
if delete_pixels {
|
||||
for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = crop_surface(surf, r));
|
||||
delete_cropped_pixels(&mut doc.layers, r);
|
||||
}
|
||||
translate_doc(doc, cmd, dx, dy)?;
|
||||
doc.size = Size::new(r.width(), r.height());
|
||||
@@ -753,7 +773,14 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
||||
if delete {
|
||||
let keep = turned_crop_source(size, new, angle, target);
|
||||
s.edit("Crop", |doc, _| {
|
||||
for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = crop_surface(surf, keep));
|
||||
// Type and smart objects stay whole, as in the crop itself: the rotation re-renders
|
||||
// them, but a smart object whose source is unavailable keeps its cache.
|
||||
delete_cropped_pixels(&mut doc.layers, keep);
|
||||
for_each_layer(&mut doc.layers, &mut |l| {
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = crop_surface(&m.surface, keep);
|
||||
}
|
||||
});
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = crop_surface(&ch.surface, keep);
|
||||
}
|
||||
@@ -1093,6 +1120,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete Cropped Pixels on a shrinking crop deletes the cropped area from every stored video
|
||||
/// frame (and the original footage), not just the displayed one (#4956).
|
||||
#[test]
|
||||
fn delete_cropped_pixels_crops_stored_video_frames() {
|
||||
let mut s = session();
|
||||
s.edit("video", |doc, active| {
|
||||
let l = doc.layer_mut(active.unwrap()).unwrap();
|
||||
let red = l.surface().unwrap().clone();
|
||||
let mut blue = red.clone();
|
||||
blue.fill_rect(Rect::new(0, 0, 40, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||||
let mut v = photocraft_doc::VideoData::new(vec![red.clone(), blue.clone()], 24.0);
|
||||
v.original_frames = Some(vec![red, blue]);
|
||||
l.video = Some(v);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20, "deleteCroppedPixels": true})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(25, 20));
|
||||
let v = doc(&s).layers[1].video.as_ref().unwrap();
|
||||
let originals = v.original_frames.as_ref().unwrap();
|
||||
assert_eq!((v.frames.len(), originals.len()), (2, 2));
|
||||
for frames in [&v.frames, originals] {
|
||||
// The red square (10..20, 5..15) keeps only its right half; the full blue frame
|
||||
// keeps exactly the 25×20 crop.
|
||||
assert_eq!(frames[0].content_bounds(), Rect::new(0, 5, 5, 15));
|
||||
assert_eq!(frames[0].pixel(2, 10), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(frames[1].content_bounds(), Rect::new(0, 0, 25, 20));
|
||||
assert_eq!(frames[1].pixel(24, 19), vec![0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample on with only a new resolution keeps the print size, as Photoshop's Image Size
|
||||
/// does (an action's `imageSize` with `Rslt` alone); without `keepPrintSize` only the
|
||||
/// resolution changes.
|
||||
@@ -1400,6 +1458,51 @@ mod tests {
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(1, 8), [0.0; 4]);
|
||||
}
|
||||
|
||||
/// Delete Cropped Pixels deletes pixel-layer data only: type and smart objects keep what lies
|
||||
/// outside the frame, so moving them later doesn't show them cut at the old edge.
|
||||
#[test]
|
||||
fn delete_cropped_pixels_keeps_type_and_smart_objects_whole() {
|
||||
let mut s = session();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap();
|
||||
let bounds = |s: &Session| -> Vec<Rect> { doc(s).layers[1..].iter().map(|l| l.surface().unwrap().content_bounds()).collect() };
|
||||
let before = bounds(&s);
|
||||
assert!(before.iter().all(|b| b.x0 < 15 && b.x1 > 15), "both cross the crop edge: {before:?}");
|
||||
s.execute("image.crop", json!({"x": 15, "y": 0, "width": 25, "height": 20})).unwrap();
|
||||
assert!(matches!(doc(&s).layers[1].content, LayerContent::Smart(_)) && matches!(doc(&s).layers[2].content, LayerContent::Text(_)));
|
||||
assert_eq!(bounds(&s), before.iter().map(|b| b.translate(-15, 0)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// A turned crop deleting pixels keeps type and smart objects whole too, also a smart object
|
||||
/// whose source is unavailable (the rotation can't re-render it, so it keeps its cache): each
|
||||
/// ends up exactly as the same turned crop with Delete Cropped Pixels off leaves it.
|
||||
#[test]
|
||||
fn turned_crop_deleting_pixels_keeps_type_and_smart_objects_whole() {
|
||||
let [deleted, kept] = [true, false].map(|delete| {
|
||||
let mut s = session();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.edit("unlink", |doc, active| {
|
||||
if let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content {
|
||||
sm.source = photocraft_doc::SmartSource::Linked { path: "missing-photocraft-test-source.psd".into() };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.create", json!({"x": 10, "y": 15, "text": "Hi", "size": 12})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 20, "y": 0, "width": 20, "height": 20, "angle": 2.0, "deleteCroppedPixels": delete})).unwrap();
|
||||
s
|
||||
});
|
||||
let (a, b) = (doc(&deleted), doc(&kept));
|
||||
assert!(matches!(a.layers[1].content, LayerContent::Smart(_)) && matches!(a.layers[2].content, LayerContent::Text(_)));
|
||||
for i in [1, 2] {
|
||||
let (sa, sb) = (a.layers[i].surface().unwrap(), b.layers[i].surface().unwrap());
|
||||
let r = sb.content_bounds();
|
||||
assert!(r.x0 < 0, "layer {i} reaches past the crop's left edge: {r:?}");
|
||||
assert_eq!(sa.content_bounds(), r, "layer {i}");
|
||||
assert_eq!(sa.read_region(r), sb.read_region(r), "layer {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Background keeps the pixels a crop with Delete Cropped Pixels off hid: extending the
|
||||
/// canvas over them again (Reveal All, Canvas Size) shows them rather than painting them over
|
||||
/// with the background colour (#7086).
|
||||
|
||||
Reference in New Issue
Block a user