collective

Browse / github.com/storytold/designcraft / patch

CMYK view: keep image plates cached; convert large images only in view

Editing freezes for half a second or more per step with large placed images in a CMYK blend space ### What happens With a few large placed images and transparency in the spread, every edit makes the canvas freeze. Moving or drawing an object, or nudging it with the arrow keys, takes about half a second per step in a 1400 × 900 window, and several seconds in a large window or zoomed in. Aligning objects by hand is almost impossible. The CPU is not the limit; it's the time each new frame takes. ### Steps 1. A document in a CMYK blend space (the default for print) with a 3840 × 5760 PSD with transparency and two 1254 × 1254 PNGs placed on one spread. 2. Draw a frame or move an object over

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1534, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+263 −37 in 1 file
Review
pending
Shared
2026-10-28
Id
dfc50c7cc2136b6f2209069c

How it went

No other operator has reported using it yet.

Diff

raw · crates/render/src/lib.rs

diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -895,23 +895,36 @@ impl Renderer {         let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));         let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h);         let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return };+        let sx = page_w / pm.width().max(1) as f64;+        let sy = page_h / pm.height().max(1) as f64;+        let to_image = Affine::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy);         // A coloured image is already in the plate's inks (its colours come from `color_of`).-        let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) {-            (Some(coloured), _) => coloured,-            (None, Some(plate)) => plate_pixmap(&pm, plate),-            (None, None) => pm,+        let (pm, at) = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) {+            (Some(coloured), _) => (coloured, (0, 0)),+            (None, Some(plate)) => {+                // Spread space → the image's pixels.+                let to_pixels = (xf * g.xf * to_image).inverse();+                // The document is walked for effects only when a large image needs it.+                let area = || {+                    let reach = image_effect_reach(f.doc, g).unwrap_or_else(|| effect_reach(f.doc));+                    f.visible.inflate(reach, reach)+                };+                match plate_part(&pm, plate, to_pixels, area) {+                    Some(part) => part,+                    None => return,+                }+            }+            (None, None) => (pm, (0, 0)),         };         // Only where the page is: a box reaching past it (a media box larger than the crop box)         // shows nothing there.         let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page);         ctx.set_transform(f.view * xf * g.xf);-        let sx = page_w / pm.width().max(1) as f64;-        let sy = page_h / pm.height().max(1) as f64;         ctx.set_paint(vello_cpu::Image {             image: vello_cpu::ImageSource::Pixmap(pm),             sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),         });-        ctx.set_paint_transform(Affine::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy));+        ctx.set_paint_transform(to_image * Affine::translate((f64::from(at.0), f64::from(at.1))));         ctx.fill_rect(&rect);         ctx.reset_paint_transform();     }@@ -964,31 +977,95 @@ pub(crate) fn cmyk_in(table: &[[f32; 4]], rgb: [f32; 3]) -> [f32; 4] {     out } -/// An image's ink coverage on one process plate, as grey (cached per image and plate).-fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync::Arc<vello_cpu::Pixmap> {-    // Keep source allocations alive (their addresses are keys), invalidate on colour settings,-    // and cap derived raster memory rather than merely counting differently sized images.-    type Entry = (std::sync::Arc<vello_cpu::Pixmap>, std::sync::Arc<vello_cpu::Pixmap>);-    type Cache = (designcraft_color::cms::ColorSettings, std::collections::HashMap<(usize, u8), Entry>, usize);-    const BUDGET: usize = 64 << 20;-    static CACHE: std::sync::Mutex<Option<Cache>> = std::sync::Mutex::new(None);-    let key = (std::sync::Arc::as_ptr(pm) as usize, plate);-    let settings = designcraft_color::cms::active_settings();-    {-        let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);-        if let Some((previous, map, _)) = guard.as_ref() {-            if *previous == settings {-                if let Some((_, derived)) = map.get(&key) {-                    return derived.clone();-                }-            } else {-                *guard = None;-            }+/// How far (points) any effect in `doc` can carry an image's pixels: an image is converted to a+/// plate only where the view can show it, widened by this so shadows and glows near the edge of+/// the view still find their pixels. Generous on purpose: it only makes the converted part bigger.+fn effect_reach(doc: &Document) -> f64 {+    let mut reach = 0.0;+    let mut add = |it: &Item| {+        if it.effects.any() {+            reach += fx::outset(it) + 144.0;         }+    };+    for it in doc.spreads.iter().flat_map(|sp| &sp.items).chain(doc.parents.iter().flat_map(|p| &p.items)) {+        it.walk(&mut add);+    }+    reach+}++/// How far the effects of the object showing graphic `g` and of the groups around it can carry+/// its pixels: only those move or spread this image. None when the graphic isn't found on a page+/// (an anchored object in a story): then [`effect_reach`] covers it.+fn image_effect_reach(doc: &Document, g: &designcraft_doc::Graphic) -> Option<f64> {+    fn find(it: &Item, g: &designcraft_doc::Graphic, around: f64, depth: usize) -> Option<f64> {+        let reach = around + if it.effects.any() { fx::outset(it) + 144.0 } else { 0.0 };+        if it.graphic().is_some_and(|own| std::ptr::eq(own, g)) {+            return Some(reach);+        }+        if depth >= 64 {+            return None;+        }+        it.children().iter().find_map(|c| find(c, g, reach, depth + 1))+    }+    doc.spreads.iter().flat_map(|sp| &sp.items).chain(doc.parents.iter().flat_map(|p| &p.items)).find_map(|it| find(it, g, 0.0, 0))+}++/// An image's plate (see [`plate_pixmap`]) where `area` (spread space; `to_pixels` maps it to the+/// image's pixels) can show it, and where that part starts in the image. Small images are+/// converted whole and cached. A large one (a 3840 × 5760 photo at full resolution, zoomed in)+/// would take seconds to convert and crowd the other images out of the cache, so only the part in+/// view is converted. None: none of it is in view.+fn plate_part(+    pm: &std::sync::Arc<vello_cpu::Pixmap>,+    plate: u8,+    to_pixels: Affine,+    area: impl FnOnce() -> Rect,+) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, (u16, u16))> {+    let (w, h) = (pm.width(), pm.height());+    if plate_bytes(w, h) <= PLATE_CACHE_BYTES / 8 {+        return Some((plate_pixmap(pm, plate), (0, 0)));+    }+    // Bilinear and bicubic sampling read a few pixels around each one shown.+    let r = to_pixels.transform_rect_bbox(area()).inflate(4.0, 4.0);+    if !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()) {+        return Some((plate_pixmap(pm, plate), (0, 0)));+    }+    let clamp = |v: f64, max: u16| v.clamp(0.0, f64::from(max)) as u16;+    let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h));+    if x1 <= x0 || y1 <= y0 {+        return None;+    }+    // All or nearly all of it in view (three quarters or more): convert the whole plate once and+    // keep it, as long as it takes at most half the cache (kept whole, a bigger one would push+    // every other image out). Converting it in parts would redo nearly all of it on every render.+    let part_area = u64::from(x1 - x0) * u64::from(y1 - y0);+    let whole_area = u64::from(w) * u64::from(h);+    if part_area.saturating_mul(4) >= whole_area.saturating_mul(3) && plate_bytes(w, h) <= PLATE_CACHE_BYTES / 2 {+        return Some((plate_pixmap(pm, plate), (0, 0)));+    }+    let row = usize::from(w);+    let data = pm.data();+    let mut part = Vec::with_capacity(usize::from(x1 - x0) * usize::from(y1 - y0));+    for y in usize::from(y0)..usize::from(y1) {+        let start = y * row;+        part.extend_from_slice(data.get(start + usize::from(x0)..start + usize::from(x1))?);     }     let table = cmyk_table();-    let data: Vec<vello_cpu::color::PremulRgba8> = pm-        .data()+    let part = plate_pixels(&part, &table, plate);+    Some((std::sync::Arc::new(vello_cpu::Pixmap::from_parts(part, x1 - x0, y1 - y0)), (x0, y0)))+}++/// Plate conversions cached per image and plate: at most this much derived raster memory.+const PLATE_CACHE_BYTES: usize = 192 << 20;++/// What one plate of a `w` × `h` image costs the cache (premultiplied RGBA8).+fn plate_bytes(w: u16, h: u16) -> usize {+    usize::from(w).saturating_mul(usize::from(h)).saturating_mul(4)+}++/// Premultiplied pixels' ink coverage on `plate` (4: C, M and Y complements as R, G and B).+fn plate_pixels(pixels: &[vello_cpu::color::PremulRgba8], table: &[[f32; 4]], plate: u8) -> Vec<vello_cpu::color::PremulRgba8> {+    pixels         .iter()         .map(|p| {             if p.a == 0 {@@ -996,7 +1073,7 @@ fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync:             }             let k = 255.0 / p.a as f32;             let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0));-            let cmyk = cmyk_in(&table, rgb);+            let cmyk = cmyk_in(table, rgb);             let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8;             if plate == 4 {                 return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a };@@ -1004,20 +1081,59 @@ fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync:             let v = ink(plate.min(3) as usize);             vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a }         })-        .collect();+        .collect()+}++/// An image's ink coverage on one process plate, as grey (cached per image and plate).+fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync::Arc<vello_cpu::Pixmap> {+    // Keep source allocations alive (their addresses are keys), invalidate on colour settings,+    // and cap derived raster memory. A full cache lets go of the plates used longest ago: every+    // CMYK view render draws each image twice (two ink passes), so dropping the whole cache when+    // it filled made a few placed images miss on every render and converted them again each time.+    type Entry = (std::sync::Arc<vello_cpu::Pixmap>, std::sync::Arc<vello_cpu::Pixmap>, u64);+    type Cache = (designcraft_color::cms::ColorSettings, std::collections::HashMap<(usize, u8), Entry>, usize, u64);+    static CACHE: std::sync::Mutex<Option<Cache>> = std::sync::Mutex::new(None);+    let key = (std::sync::Arc::as_ptr(pm) as usize, plate);+    let settings = designcraft_color::cms::active_settings();+    {+        let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+        if let Some((previous, map, _, clock)) = guard.as_mut() {+            if *previous == settings {+                *clock += 1;+                if let Some((_, derived, used_at)) = map.get_mut(&key) {+                    *used_at = *clock;+                    return derived.clone();+                }+            } else {+                *guard = None;+            }+        }+    }+    let table = cmyk_table();+    #[cfg(test)]+    PLATE_CONVERSIONS.with(|n| n.set(n.get() + 1));+    let data = plate_pixels(pm.data(), &table, plate);     let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()));-    let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8);-    if bytes <= BUDGET {+    let bytes = plate_bytes(pm.width(), pm.height());+    if bytes <= PLATE_CACHE_BYTES {         let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);-        let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0));-        if *previous != settings || used.saturating_add(bytes) > BUDGET {+        let (previous, map, used, clock) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0, 0));+        if *previous != settings {             map.clear();             *used = 0;             *previous = settings;         }-        if map.insert(key, (pm.clone(), out.clone())).is_none() {-            *used += bytes;+        while used.saturating_add(bytes) > PLATE_CACHE_BYTES {+            let Some(oldest) = map.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };+            if let Some((_, gone, _)) = map.remove(&oldest) {+                *used = used.saturating_sub(plate_bytes(gone.width(), gone.height()));+            }+        }+        *clock += 1;+        if let Some((_, replaced, _)) = map.insert(key, (pm.clone(), out.clone(), *clock)) {+            *used = used.saturating_sub(plate_bytes(replaced.width(), replaced.height()));         }+        *used = used.saturating_add(bytes);     }     out }@@ -1350,6 +1466,12 @@ fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap {     Pixmap::from_parts(data, raster.width as u16, raster.height as u16) } +#[cfg(test)]+thread_local! {+    /// How many whole images [`plate_pixmap`] converted to a plate on this thread.+    static PLATE_CONVERSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };+}+ #[cfg(test)] thread_local! {     /// How many PDF pages [`render_pdf_page`] rasterized on this thread.@@ -1567,6 +1689,66 @@ mod tests {     use designcraft_doc::build::NewDocument;     use designcraft_doc::{Fill, ParaFormat}; +    /// A `w` × `h` image with a different colour in every pixel.+    fn ramp(w: u16, h: u16) -> std::sync::Arc<vello_cpu::Pixmap> {+        let data = (0..usize::from(w) * usize::from(h))+            .map(|i| vello_cpu::color::PremulRgba8 { r: (i % 251) as u8, g: (i / 7 % 241) as u8, b: (i / 13 % 239) as u8, a: 255 })+            .collect();+        std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, w, h))+    }++    /// A CMYK view draws every image twice per render (two ink passes). The plate cache used to+    /// count each plate twice and empty itself when full, so three placed photos missed on every+    /// render and an edit took half a second; now they all stay cached.+    #[test]+    fn plates_of_the_images_in_view_stay_cached_across_renders() {+        let images = [ramp(1600, 1200), ramp(1500, 1300), ramp(1400, 1400)];+        let before = PLATE_CONVERSIONS.with(std::cell::Cell::get);+        for _ in 0..3 {+            for plate in [4, 3] {+                for pm in &images {+                    plate_pixmap(pm, plate);+                }+            }+        }+        assert_eq!(PLATE_CONVERSIONS.with(std::cell::Cell::get) - before, 6, "each image and plate is converted once");+    }++    /// A large image is converted only where the view shows it, with the same inks as converting+    /// it whole.+    #[test]+    fn a_large_image_converts_only_the_part_in_view() {+        let pm = ramp(4000, 2500);+        assert!(plate_bytes(4000, 2500) > PLATE_CACHE_BYTES / 8, "large enough to be converted in parts");+        let before = PLATE_CONVERSIONS.with(std::cell::Cell::get);+        let (part, at) = plate_part(&pm, 4, Affine::IDENTITY, || Rect::new(1000.0, 500.0, 1100.0, 560.0)).unwrap();+        assert_eq!(PLATE_CONVERSIONS.with(std::cell::Cell::get), before, "the whole image was not converted");+        assert_eq!(at, (996, 496));+        assert_eq!((part.width(), part.height()), (108, 68));+        let table = cmyk_table();+        let row = usize::from(pm.width());+        for (y, x) in [(0usize, 0usize), (30, 50), (67, 107)] {+            let source = pm.data()[(usize::from(at.1) + y) * row + usize::from(at.0) + x];+            assert_eq!(part.data()[y * 108 + x], plate_pixels(&[source], &table, 4)[0]);+        }+        assert!(plate_part(&pm, 4, Affine::IDENTITY, || Rect::new(5000.0, 0.0, 5100.0, 50.0)).is_none(), "out of view: nothing");+        // All of it in view: converted whole once, then taken from the cache.+        let before = PLATE_CONVERSIONS.with(std::cell::Cell::get);+        for _ in 0..3 {+            let (whole, at) = plate_part(&pm, 4, Affine::IDENTITY, || Rect::new(-10.0, -10.0, 4010.0, 2510.0)).unwrap();+            assert_eq!(((whole.width(), whole.height()), at), ((4000, 2500), (0, 0)));+        }+        assert_eq!(PLATE_CONVERSIONS.with(std::cell::Cell::get) - before, 1, "the whole plate is converted once and cached");+        // Nearly all of it in view (90 % of the width): the cached whole plate, not a new part.+        let before = PLATE_CONVERSIONS.with(std::cell::Cell::get);+        let (whole, at) = plate_part(&pm, 4, Affine::IDENTITY, || Rect::new(200.0, -10.0, 3800.0, 2510.0)).unwrap();+        assert_eq!(((whole.width(), whole.height()), at), ((4000, 2500), (0, 0)));+        assert_eq!(PLATE_CONVERSIONS.with(std::cell::Cell::get), before, "taken from the cache");+        let small = ramp(64, 64);+        let (whole, at) = plate_part(&small, 3, Affine::IDENTITY, || Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap();+        assert_eq!(((whole.width(), whole.height()), at), ((64, 64), (0, 0)), "a small image is converted whole and cached");+    }+     #[test]     fn eps_source_decodes_like_its_preview() {         let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";@@ -1932,6 +2114,50 @@ mod tests {         assert_eq!(px(true, &mut r)[..3], [0, 0, 0]);     } +    /// A large image is widened only by the effects that move its own pixels (its object's and+    /// the groups' around it), not by a big shadow elsewhere in the document.+    #[test]+    fn an_images_effect_reach_is_its_own() {+        let mut d = Document::new(&NewDocument::default());+        let lid = d.default_layer();+        let graphic = |aid| designcraft_doc::Graphic {+            layer_options: None,+            asset: aid,+            size: (100.0, 100.0),+            xf: Affine::IDENTITY,+            clip: None,+            auto_fit: Default::default(),+            fit_align: 4,+            crop: [0.0; 4],+            wrap: Default::default(),+            fill: Default::default(),+        };+        let item = |d: &mut Document, shadow: f64, with_graphic: bool| {+            let mut it = Item::new(+                designcraft_doc::ItemId(d.alloc()),+                lid,+                designcraft_doc::Shape::Rectangle,+                designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),+            );+            if with_graphic {+                it.content = designcraft_doc::Content::Graphic(graphic(designcraft_doc::AssetId(1)));+            }+            if shadow > 0.0 {+                it.effects.drop_shadow.on = true;+                it.effects.drop_shadow.distance = shadow;+            }+            d.insert_item(SpreadRef::Doc(0), it.clone(), None).unwrap();+            it.id+        };+        let image = item(&mut d, 0.0, true);+        item(&mut d, 500.0, false);+        let g = d.item(image).unwrap().graphic().unwrap().clone();+        let own = d.item(image).unwrap().graphic().unwrap();+        assert_eq!(image_effect_reach(&d, own), Some(0.0), "no effect on the image's own object");+        assert!(effect_reach(&d) > 500.0, "the document-wide reach counts the other shadow");+        assert_eq!(image_effect_reach(&d, &g), None, "a graphic not in the document: the document-wide reach");+    }+     #[test]     fn display_performance_fast_draws_grey_boxes() {         let mut d = Document::new(&NewDocument::default());