diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index d52522ca35..9259c5b2bd 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -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 { 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::>()); + } + + /// 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).