diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 79f020c9d..81f0615c6 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -33,6 +33,7 @@ pub fn start() { let q = query(); let force_cpu = q.contains("cpu"); let mut options = eframe::WebOptions::default(); + photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup); if q.contains("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 56460ab84..a25a83cec 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -98,7 +98,7 @@ fn main() -> eframe::Result { // Read the main display's ICC profile while the window opens (colour-managed canvas). let monitor = monitor_profile::detect_async(); - let options = eframe::NativeOptions { + let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_icon(app_icon()) .with_app_id(APP_ID) @@ -111,6 +111,8 @@ fn main() -> eframe::Result { .with_title_shown(false), ..Default::default() }; + // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. + photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup); eframe::run_native( "Photocraft", options, diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 05b524cbf..d037bd6f1 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -932,20 +932,34 @@ pub fn apply_tiled( } (*t, data) }; + // Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each + // group is written before the next starts, so a huge layer never holds all its results as + // floats at once. #[cfg(not(target_arch = "wasm32"))] - let results: Vec<(Rect, Vec)> = { - use rayon::prelude::*; - tiles.par_iter().map(run).collect() - }; + let threads = rayon::current_num_threads(); #[cfg(target_arch = "wasm32")] - let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect(); - for (t, data) in results { - out.write_region(t, &data); + let threads = 1; + let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::(); + let group = (RESULT_BUDGET / per_tile.max(1)).max(threads).max(1); + for chunk in tiles.chunks(group) { + #[cfg(not(target_arch = "wasm32"))] + let results: Vec<(Rect, Vec)> = { + use rayon::prelude::*; + chunk.par_iter().map(run).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec<(Rect, Vec)> = chunk.iter().map(run).collect(); + for (t, data) in results { + out.write_region(t, &data); + } } out.prune(); out } +/// Bytes of float filter results held at once by [`apply_tiled`]. +const RESULT_BUDGET: usize = 256 << 20; + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/codecs/README.md b/crates/codecs/README.md index a88bcf301..7c1891b07 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -105,7 +105,7 @@ the same. `DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } }` guards decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The -defaults are 262144 px per side, 2^28 pixels and 2 GiB. +defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm). ## Tests diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 07ecdcfb7..0d252669b 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -87,7 +87,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let (w, h) = (img.width() as usize, img.height() as usize); let layout = img.layout(); let names: &[&str] = match layout { diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index c8733deb4..d1119b020 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -153,7 +153,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); let (w16, h16) = match (u16::try_from(w), u16::try_from(h)) { (Ok(a), Ok(b)) => (a, b), diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 99fb2d62c..bf008213f 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -97,7 +97,7 @@ fn is_latin1_keyword(k: &str) -> bool { } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); let color = match img.layout() { ChannelLayout::Gray => png::ColorType::Grayscale, @@ -112,9 +112,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), }; // Big-endian sample bytes. - let mut data = img.data().to_vec(); + let mut data = std::borrow::Cow::Borrowed(img.data()); if depth == png::BitDepth::Sixteen { - for c in data.as_chunks_mut::<2>().0 { + for c in data.to_mut().as_chunks_mut::<2>().0 { let v = u16::from_ne_bytes([c[0], c[1]]); c.copy_from_slice(&v.to_be_bytes()); } diff --git a/crates/codecs/src/codecs/pnm.rs b/crates/codecs/src/codecs/pnm.rs index 94bd54208..b4f495744 100644 --- a/crates/codecs/src/codecs/pnm.rs +++ b/crates/codecs/src/codecs/pnm.rs @@ -241,7 +241,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result } pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result, CodecError> { - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); let layout = img.layout(); let mut out = Vec::new(); diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 03bfd3232..2a9f5fc06 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -360,7 +360,7 @@ where } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); let compression = match opts.tiff_compression { TiffCompression::None => tiff::encoder::Compression::Uncompressed, diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 89bfb39b0..9ad550d5c 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -99,7 +99,7 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - { return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}"))); } - let img = src.convert(plan.layout, plan.sample); + let img = src.converted(plan.layout, plan.sample); let dynimg = to_dynamic(&img)?; let mut cursor = Cursor::new(Vec::new()); match f { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index 74600e3ad..c8d33fe60 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -37,7 +37,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result image_webp::ColorType::L8, ChannelLayout::GrayA => image_webp::ColorType::La8, diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 668f72313..b6dadd5ea 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -327,6 +327,12 @@ impl Image { } } + /// [`Image::convert`], borrowing `self` when it already has that layout and sample type + /// (encoders use it so a full-size image isn't copied for nothing). + pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> { + if layout == self.layout && sample == self.sample { std::borrow::Cow::Borrowed(self) } else { std::borrow::Cow::Owned(self.convert(layout, sample)) } + } + /// Interleaved RGBA, 8 bits per sample. pub fn to_rgba8(&self) -> Vec { self.convert(ChannelLayout::Rgba, SampleType::U8).data diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index f2c8f6a97..bbdcb1eca 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -18,7 +18,10 @@ pub struct Limits { impl Default for Limits { fn default() -> Self { - Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 28, max_alloc: 2 << 30 } + // 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large + // documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). + let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc } } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 855bb4444..b23658828 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -22,6 +22,7 @@ pub mod effects; pub mod masks; pub mod multichannel; pub mod pattern; +pub mod proxy; pub mod psblend; pub mod shape_split; @@ -83,6 +84,7 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer { /// [`render`] with an explicit tile size (tests check tile independence). pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { + let tile = tile.max(1); let cx = Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), @@ -96,6 +98,9 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { // CMYK layers are read through the document's own CMYK profile (thread-local scope). let cmyk = cmyk_space(doc); let cmyk = cmyk.as_ref(); + if rect.is_empty() { + return Buffer::transparent(rect); + } if rect.width() as i32 <= tile && rect.height() as i32 <= tile { return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); @@ -105,39 +110,66 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { buf }); } - let mut tiles = Vec::new(); - let mut y = rect.y0; - while y < rect.y1 { - let mut x = rect.x0; - while x < rect.x1 { - tiles.push(Rect::new(x, y, (x + tile).min(rect.x1), (y + tile).min(rect.y1))); - x += tile; - } - y += tile; - } - let run = |t: &Rect| { + let run = |t: Rect| { photocraft_color::convert::with_cmyk_space(cmyk, || { - let mut b = multichannel::backdrop(doc, *t); + let mut b = multichannel::backdrop(doc, t); psblend::LAB_MIX.with(|l| l.set(lab)); composite_stack(&doc.layers, &mut b, &cx); psblend::LAB_MIX.with(|l| l.set(false)); b }) }; - #[cfg(not(target_arch = "wasm32"))] - let parts: Vec = { - use rayon::prelude::*; - tiles.par_iter().map(run).collect() - }; - #[cfg(target_arch = "wasm32")] - let parts: Vec = tiles.iter().map(run).collect(); - let mut out = Buffer::transparent(rect); + // Tiles are written straight into the output, one row of tiles (a band) at a time, so the + // peak is the output plus the tiles in flight, not a second full-size copy. let w = rect.width() as usize; - for part in parts { + let mut out = Buffer::transparent(rect); + let band_tiles = |y0: i32| { + let y1 = y0.saturating_add(tile).min(rect.y1); + let mut v = Vec::new(); + let mut x = rect.x0; + while x < rect.x1 { + let x1 = x.saturating_add(tile).min(rect.x1); + v.push(Rect::new(x, y0, x1, y1)); + x = x1; + } + v + }; + let put = |band: &mut [[f32; 4]], y0: i32, part: &Buffer| { let pw = part.rect.width() as usize; for (row, src) in part.px.chunks_exact(pw).enumerate() { - let o = ((part.rect.y0 - rect.y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize; - out.px[o..o + pw].copy_from_slice(src); + let o = ((part.rect.y0 - y0) as usize + row) * w + (part.rect.x0 - rect.x0) as usize; + band[o..o + pw].copy_from_slice(src); + } + }; + let band_len = w * tile as usize; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + let bands = rect.height().div_ceil(tile as u32) as usize; + if bands >= 2 * rayon::current_num_threads() { + // Enough bands to keep every core busy: one band per task, its tiles in turn. + out.px.par_chunks_mut(band_len).enumerate().for_each(|(i, band)| { + let y0 = rect.y0 + i as i32 * tile; + for t in band_tiles(y0) { + put(band, y0, &run(t)); + } + }); + } else { + // Few, wide bands: the tiles of each band in parallel. + for (i, band) in out.px.chunks_mut(band_len).enumerate() { + let y0 = rect.y0 + i as i32 * tile; + let parts: Vec = band_tiles(y0).into_par_iter().map(run).collect(); + for part in &parts { + put(band, y0, part); + } + } + } + } + #[cfg(target_arch = "wasm32")] + for (i, band) in out.px.chunks_mut(band_len).enumerate() { + let y0 = rect.y0 + i as i32 * tile; + for t in band_tiles(y0) { + put(band, y0, &run(t)); } } out @@ -153,6 +185,58 @@ pub fn cmyk_space(doc: &Document) -> Option(doc: &Document, rect: Rect, band_rows: i32, mut sink: impl FnMut(Buffer) -> Result<(), E>) -> Result<(), E> { + if rect.is_empty() { + return Ok(()); + } + let rows = band_rows_for(rect.width(), band_rows); + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(rows).min(rect.y1); + sink(render(doc, Rect::new(rect.x0, y, rect.x1, y1)))?; + y = y1; + } + Ok(()) +} + +/// Band height for [`render_bands`]: `requested` rounded up to whole tiles, or ~[`BAND_PIXELS`]. +pub fn band_rows_for(width: u32, requested: i32) -> i32 { + let want = if requested > 0 { i64::from(requested) } else { (BAND_PIXELS / u64::from(width.max(1))) as i64 }; + let tiles = (want + i64::from(RENDER_TILE) - 1) / i64::from(RENDER_TILE); + (tiles.clamp(1, 1 << 16) * i64::from(RENDER_TILE)) as i32 +} + +/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model +/// and depth), optionally flattened over an opaque `background`; rendered and written in bands, +/// so no full-size float composite is held. Tiles equal to the default pixel are pruned. +pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { + let mut s = Surface::new(fmt); + let n = fmt.channels(); + let mut data = Vec::new(); + let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + let band = match background { + Some(bg) => band.over_background(bg), + None => band, + }; + data.clear(); + data.resize(band.px.len() * n, 0.0); + for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { + photocraft_raster::from_rgba_into(&fmt, *p, out); + } + s.write_region(band.rect, &data); + Ok(()) + }); + s.prune(); + s +} + /// Composite the full canvas. pub fn flatten(doc: &Document) -> Buffer { render(doc, doc.bounds()) @@ -176,42 +260,102 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { buf } -/// Downscaled RGBA8 render of the document (nearest-neighbour sampling) for thumbnails or navigators. +/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is +/// faithful; smaller ones are reduced from the exact composite. +pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20; + +/// Downscaled RGBA8 render of the document (area-averaged) for thumbnails, navigators and +/// histograms, at most `max_side` pixels on its longer side. Large documents are reduced from a +/// downsampled proxy (about twice the thumbnail's size), so a thumbnail of a 200 MP document +/// costs milliseconds and no full-size composite; the rest stream the exact composite in bands. pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image { - // Composite once (tile-parallel), then area-average down. Rendering per thumbnail pixel - // would redo layer effects for every sample. + thumbnail_buffer(doc, max_side).to_rgba8() +} + +/// [`thumbnail`] as straight-alpha floats (for callers that colour-convert it first). +pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer { let b = doc.bounds(); - let scale = (max_side as f32 / b.width().max(b.height()).max(1) as f32).min(1.0); + let longest = b.width().max(b.height()).max(1); + let scale = (max_side as f32 / longest as f32).min(1.0); let w = ((b.width() as f32 * scale).round() as u32).max(1); let h = ((b.height() as f32 * scale).round() as u32).max(1); - let full = flatten(doc); + let k = longest / max_side.max(1).saturating_mul(2); + if k >= 2 && doc.size.area() > PROXY_THUMBNAIL_PIXELS && proxy::proxy_faithful(doc) { + render_reduced(&proxy::proxy_document(doc, k), w, h) + } else { + render_reduced(doc, w, h) + } +} + +/// The document's composite area-averaged (premultiplied) down to `w`×`h` (clamped to the +/// document size), rendered in bands so no full-size composite is held. +pub fn render_reduced(doc: &Document, w: u32, h: u32) -> Buffer { + render_reduced_in_bands(doc, w, h, 0) +} + +/// [`render_reduced`] with an explicit band height (see [`render_bands`]). +fn render_reduced_in_bands(doc: &Document, w: u32, h: u32, band_rows: i32) -> Buffer { + let b = doc.bounds(); let (fw, fh) = (b.width() as usize, b.height() as usize); - let mut img = Rgba8Image::new(w, h); - for ty in 0..h as usize { - let (y0, y1) = (ty * fh / h as usize, ((ty + 1) * fh / h as usize).max(ty * fh / h as usize + 1).min(fh)); - for tx in 0..w as usize { - let (x0, x1) = (tx * fw / w as usize, ((tx + 1) * fw / w as usize).max(tx * fw / w as usize + 1).min(fw)); - // Premultiplied average so transparent pixels don't darken edges. - let mut acc = [0.0f32; 4]; - for y in y0..y1 { - for x in x0..x1 { - let p = full.px[y * fw + x]; - for c in 0..3 { - acc[c] += p[c] * p[3]; + let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); + let rect = Rect::from_xywh(0, 0, w as u32, h as u32); + if fw == 0 || fh == 0 { + return Buffer::transparent(rect); + } + if (w, h) == (fw, fh) { + let mut out = render(doc, b); + out.rect = rect; + return out; + } + // Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n). + let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)); + let map = |f: usize, n: usize| { + let mut m = vec![0u32; f]; + for t in 0..n { + let (a, z) = span(t, f, n); + m[a..z].fill(t as u32); + } + m + }; + let (cols, rows) = (map(fw, w), map(fh, h)); + let mut acc = vec![[0.0f32; 4]; w * h]; + let ok: Result<(), ()> = render_bands(doc, b, band_rows, |band| { + let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); + let (t0, t1) = (rows[by0] as usize, rows[by1 - 1] as usize + 1); + // Each output row sums its source rows in this band (in order, top to bottom). + let sum_row = |t: usize, out: &mut [[f32; 4]]| { + let (a, z) = span(t, fh, h); + for y in a.max(by0)..z.min(by1) { + let src = &band.px[(y - by0) * fw..(y - by0 + 1) * fw]; + for (p, &c) in src.iter().zip(&cols) { + let o = &mut out[c as usize]; + for i in 0..3 { + o[i] += p[i] * p[3]; } - acc[3] += p[3]; + o[3] += p[3]; } } - let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; - let a = acc[3] / n; - let px = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], a] } else { [0.0; 4] }; - let o = (ty * w as usize + tx) * 4; - for (dst, v) in img.pixels[o..o + 4].iter_mut().zip(px) { - *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - } + }; + let part = &mut acc[t0 * w..t1 * w]; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + part.par_chunks_mut(w).enumerate().for_each(|(i, out)| sum_row(t0 + i, out)); } + #[cfg(target_arch = "wasm32")] + for (i, out) in part.chunks_mut(w).enumerate() { + sum_row(t0 + i, out); + } + Ok(()) + }); + debug_assert!(ok.is_ok()); + for (i, p) in acc.iter_mut().enumerate() { + let ((x0, x1), (y0, y1)) = (span(i % w, fw, w), span(i / w, fh, h)); + let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; + let a = p[3]; + *p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] }; } - img + Buffer { rect, px: acc } } /// Composite a sibling list (bottom→top) onto `backdrop`. diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs new file mode 100644 index 000000000..04c385f21 --- /dev/null +++ b/crates/compose/src/proxy.rs @@ -0,0 +1,114 @@ +//! Downsampled proxy documents: every pixel surface scaled down by an integer factor `k`, so a +//! composite of the proxy approximates the document's composite at 1/k resolution for ~1/k² of +//! the work. Used for interactive previews and for thumbnails of large documents (navigator, +//! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP +//! document would cost seconds and gigabytes. + +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). +pub fn downsample(s: &Surface, k: u32) -> Surface { + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let b = s.content_bounds(); + if b.is_empty() || k <= 1 { + return if k <= 1 { s.clone() } else { out }; + } + // Surfaces span at most ±2^30 px; larger factors reduce everything to one pixel anyway. + let k = k.min(1 << 20) as i32; + let bpp = s.format().bytes_per_pixel(); + let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); + let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); + let w = (x1 - x0).max(0) as usize; + let mut row = vec![0u8; w * bpp]; + for oy in y0..y1 { + let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); + for (i, px) in row.chunks_exact_mut(bpp).enumerate() { + px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); + } + out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + } + out +} + +fn shrink_layer(l: &mut Layer, k: u32) { + if let Some(m) = &mut l.mask { + m.surface = downsample(&m.surface, k); + } + if let Some(fc) = &mut l.fill_cache { + fc.surface = downsample(&fc.surface, k); + } + match &mut l.content { + LayerContent::Raster(s) => *s = downsample(s, k), + LayerContent::Group(g) => { + for c in &mut g.children { + shrink_layer(c, k); + } + } + LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} + } +} + +/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). +pub fn proxy_document(doc: &Document, k: u32) -> Document { + let mut p = doc.clone(); + if k <= 1 { + return p; + } + p.size = Size::new(doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)); + for l in &mut p.layers { + shrink_layer(l, k); + } + // Spot channels are printed under Multichannel composites. + for c in &mut p.channels { + c.surface = downsample(&c.surface, k); + } + p.selection = None; + p +} + +/// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in +/// document pixels) and vector masks (paths in document coordinates) would not scale with it. +pub fn proxy_faithful(doc: &Document) -> bool { + doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled)) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + + #[test] + fn downsample_picks_every_kth_pixel() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + s.write_pixel(4, 4, &[0.0, 0.0, 1.0, 1.0]); + let d = downsample(&s, 4); + assert_eq!(d.pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.pixel(1, 1), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(d.pixel(2, 2)[3], 0.0); + } + + #[test] + fn downsample_handles_negative_coordinates() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(-8, -8, 0, 0), &[0.0, 1.0, 0.0, 1.0]); + let d = downsample(&s, 4); + assert_eq!(d.pixel(-1, -1), vec![0.0, 1.0, 0.0, 1.0]); + assert_eq!(d.pixel(0, 0)[3], 0.0); + } + + #[test] + fn proxy_keeps_structure_and_ids() { + let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let p = proxy_document(&doc, 4); + assert_eq!((p.size.width, p.size.height), (1000, 750)); + assert_eq!(p.layers[0].id, doc.layers[0].id); + assert_eq!(p.layers[0].surface().unwrap().pixel(999, 749), vec![1.0; 4]); + assert!(proxy_faithful(&doc)); + } +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3a61cbfe2..9aca7f013 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1008,3 +1008,117 @@ fn levels_matches_photoshop() { assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4); assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6); } + +/// A tall document with soft content, a translucent region and an adjustment. +fn tall_doc(w: u32, h: u32) -> Document { + let mut d = doc_white(w, h); + let mut l = Layer::raster("grad", PixelFormat::RGBA8); + let s = l.surface_mut().unwrap(); + for y in 0..h as i32 { + s.fill_rect(Rect::new(0, y, w as i32 / 2, y + 1), &[y as f32 / h as f32, 0.3, 1.0 - y as f32 / h as f32, 1.0]); + } + s.fill_rect(Rect::new(w as i32 / 2, 0, w as i32, h as i32 / 3), &[0.9, 0.1, 0.1, 0.4]); + d.layers.push(l); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + d +} + +#[test] +fn render_tiled_in_place_matches_one_tile_with_many_bands() { + // Enough bands for the band-parallel path, and few for the tile-parallel one. + let d = tall_doc(37, 900); + let whole = render_tiled(&d, d.bounds(), 10_000); + for tile in [5, 64, 300] { + assert_eq!(render_tiled(&d, d.bounds(), tile), whole, "tile {tile}"); + } + let r = Rect::new(3, 17, 30, 811); + assert_eq!(render_tiled(&d, r, 4).px, render_tiled(&d, r, 10_000).px); + assert!(render_tiled(&d, Rect::new(5, 5, 5, 900), 4).px.is_empty()); +} + +#[test] +fn render_bands_cover_the_rect_in_order() { + let d = tall_doc(20, 700); + let r = Rect::new(2, 10, 18, 690); + let mut rows = Vec::new(); + let mut px = Vec::new(); + render_bands(&d, r, 100, |b| -> Result<(), ()> { + assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1)); + rows.push((b.rect.y0, b.rect.y1)); + px.extend(b.px); + Ok(()) + }) + .unwrap(); + // 100 rows round up to one 256-row tile band. + assert_eq!(rows, vec![(10, 266), (266, 522), (522, 690)]); + assert_eq!(px, render(&d, r).px); + // The sink's error stops the render. + let mut n = 0; + assert_eq!( + render_bands(&d, r, 1, |_| { + n += 1; + Err("stop") + }), + Err("stop") + ); + assert_eq!(n, 1); + assert_eq!(band_rows_for(14_000, 0) % RENDER_TILE, 0); + assert!(band_rows_for(1, 0) > 0 && band_rows_for(0, -5) > 0); +} + +/// The previous thumbnail: flatten, then area-average (premultiplied). +fn reference_reduce(doc: &Document, w: usize, h: usize) -> Vec<[f32; 4]> { + let full = flatten(doc); + let (fw, fh) = (full.rect.width() as usize, full.rect.height() as usize); + let mut out = Vec::new(); + for ty in 0..h { + let (y0, y1) = (ty * fh / h, ((ty + 1) * fh / h).max(ty * fh / h + 1).min(fh)); + for tx in 0..w { + let (x0, x1) = (tx * fw / w, ((tx + 1) * fw / w).max(tx * fw / w + 1).min(fw)); + let mut acc = [0.0f32; 4]; + for y in y0..y1 { + for x in x0..x1 { + let p = full.px[y * fw + x]; + for c in 0..3 { + acc[c] += p[c] * p[3]; + } + acc[3] += p[3]; + } + } + let n = ((y1 - y0) * (x1 - x0)).max(1) as f32; + out.push(if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / n] } else { [0.0; 4] }); + } + } + out +} + +#[test] +fn reduced_render_matches_the_full_composite_averaged() { + let d = tall_doc(53, 1000); + for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 512), (1, 1, 256)] { + let got = render_reduced_in_bands(&d, w, h, band); + assert_eq!((got.rect.width(), got.rect.height()), (w, h)); + assert_eq!(got.px, reference_reduce(&d, w as usize, h as usize), "{w}x{h} band {band}"); + } + // Full size is the composite itself; an empty document reduces to transparency. + assert_eq!(render_reduced(&d, 53, 1000).px, flatten(&d).px); + let empty = Document::new("e", Size::new(0, 0), ColorMode::Rgb, SampleType::U8); + assert_eq!(render_reduced(&empty, 4, 4).px, vec![[0.0; 4]]); +} + +#[test] +fn large_documents_thumbnail_from_a_proxy() { + // 4200 × 4200 (17.6 MP) is above PROXY_THUMBNAIL_PIXELS: the proxy path must still give a + // thumbnail of the right size whose colours match the exact reduction. + let mut d = doc_white(4200, 4200); + d.layers.push(solid_layer("red", Rect::new(0, 0, 2100, 4200), [1.0, 0.0, 0.0, 1.0])); + let t = thumbnail(&d, 100); + assert_eq!((t.width, t.height), (100, 100)); + let at = |x: usize, y: usize| t.pixels[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4].to_vec(); + assert_eq!(at(10, 50), vec![255, 0, 0, 255]); + assert_eq!(at(90, 50), vec![255, 255, 255, 255]); + // Effects don't scale with a proxy: such documents reduce the exact composite. + assert!(proxy::proxy_faithful(&d)); + d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + assert!(!proxy::proxy_faithful(&d)); +} diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index cdfe3c5b7..831b57ee8 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -181,6 +181,7 @@ fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Res match r { Ok(v) => { st.history.record(label, before); + st.history.trim(&st.doc); st.coalesce = None; Ok(v) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b3bfc3bf9..bab1aab30 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -586,12 +586,10 @@ fn build() -> Vec { }), cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| { s.edit("Flatten Image", |doc, active| { - let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]); let fmt = doc.pixel_format(); - let data: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); let mut bg = Layer::raster("Background", fmt); bg.locks.transparency = true; - crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data); + *crate::pixels_mut(&mut bg)? = photocraft_compose::flatten_to_surface(doc, fmt, Some([1.0, 1.0, 1.0])); *active = Some(bg.id); doc.layers = vec![bg]; Ok(()) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 0c53941cf..c87ba0b22 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -66,14 +66,8 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { /// Merged composite of the visible document as a surface in the document's format. fn merged_surface(doc: &Document) -> Surface { - let buf = photocraft_compose::flatten(doc); let fmt = doc.pixel_format(); - let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); - let data: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); - let mut s = Surface::new(fmt); - s.write_region(doc.bounds(), &data); - s.prune(); - s + photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None) } fn copy(s: &mut Session, merged: bool) -> Result { diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 6ec445b2d..e0cf8caa5 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -199,7 +199,7 @@ pub(crate) fn buffer_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) /// The flattened image of `doc` as a surface in `fmt`, placed at the origin. pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { - buffer_surface(&photocraft_compose::flatten(doc), fmt) + photocraft_compose::flatten_to_surface(doc, fmt, None) } // ---------- close / revert / save a copy / open as ---------- diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bb624a801..87a514245 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -270,6 +270,7 @@ impl Session { pub fn add_document(&mut self, doc: Document, path: Option) -> usize { let mut st = DocState::new(doc, path); st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; + st.history.max_bytes = self.prefs.get().performance.history_budget_bytes(); self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); @@ -340,6 +341,7 @@ impl Session { let st = self.active_mut().ok_or(EngineError::NoDocument)?; if key.is_none() || st.coalesce != key || !st.history.can_undo() { st.history.record(label, before); + st.history.trim(&st.doc); } st.coalesce = key; st.revision += 1; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 0c82117cb..c4d858b17 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -351,7 +351,8 @@ impl Default for Export { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Performance { - /// Memory Photocraft may use, in MB (informational budget; drives the caches below). + /// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the + /// oldest states are dropped beyond it). pub memory_usage_mb: u32, /// Undo steps kept per document (History panel states). pub history_states: u32, @@ -366,6 +367,14 @@ pub struct Performance { pub legacy_compositing: bool, } +impl Performance { + /// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it + /// the oldest history states are dropped. + pub fn history_budget_bytes(&self) -> usize { + (self.memory_usage_mb as usize).saturating_mul(1 << 20) + } +} + impl Default for Performance { fn default() -> Self { Self { @@ -1070,9 +1079,12 @@ impl Session { /// the layer-effect cache budget. pub fn apply_prefs(&mut self) { let n = self.prefs().performance.history_states.max(1) as usize; + let bytes = self.prefs().performance.history_budget_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; for st in &mut self.docs { st.history.max_states = n; + st.history.max_bytes = bytes; + st.history.trim(&st.doc); } photocraft_compose::set_effect_cache_budget(budget << 20); crate::plugin_cmds::sync_prefs(self); diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index ac0c77c8c..b75929c7c 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -5,11 +5,14 @@ use std::sync::Arc; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent}; -use photocraft_geom::{Rect, Size}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_raster::Surface; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; +/// Bytes per band when converting or exporting a band of rows at a time. +const BAND_BYTES: usize = 32 << 20; + /// Decodes a flat image into a single-layer document. pub fn import_flat(name: &str, bytes: &[u8]) -> Result { let img = codecs::decode(bytes)?; @@ -42,9 +45,21 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result) -> Result= 1.0); + let opaque = opaque_surface(s, canvas); let layout = layout_for(fmt.mode, !opaque); - let data: Vec = if opaque { vals.chunks_exact(ch).flat_map(|p| p[..ch - 1].to_vec()).collect() } else { vals }; - Image::from_normalized(w, h, layout, csample(fmt.sample), &data)? + let bps = fmt.sample.bytes(); + let keep = if opaque { (ch - 1) * bps } else { ch * bps }; + let mut data = try_buffer(n, keep)?; + let rows = (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32; + let mut y = 0; + while y < h as i32 { + let y1 = y.saturating_add(rows).min(h as i32); + let band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); + if opaque { + for px in band.chunks_exact(ch * bps) { + data.extend_from_slice(&px[..keep]); + } + } else { + data.extend_from_slice(&band); + } + y = y1; + } + Image::from_raw(w, h, layout, csample(fmt.sample), data)? } else { let count = doc.layer_count(); warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); - let buf = photocraft_compose::flatten(doc); - let opaque = buf.px.iter().all(|p| p[3] >= 1.0); // The compositor works in RGB; write RGB/gray. let gray = fmt.mode == ColorMode::Grayscale; if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { @@ -131,38 +160,45 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), - } - }; - for p in &buf.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - if !opaque { + let colors = if gray { 1 } else { 3 }; + // Rendered and quantised in bands (no full-size float composite), with alpha; the alpha + // is dropped afterwards, in place, when every pixel turned out opaque. + let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; + let mut opaque = true; + let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + opaque &= band.px.iter().all(|p| p[3] >= 1.0); + let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut put = |v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + for p in &band.px[range] { + if gray { + put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(p[0]); + put(p[1]); + put(p[2]); + } put(p[3]); } + out + }); + for part in parts { + data.extend_from_slice(&part); } - out + Ok(()) }); - let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum()); - for part in parts { - data.extend_from_slice(&part); + if opaque { + drop_alpha(&mut data, colors * cs.bytes(), cs.bytes()); } + let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque); Image::from_raw(w, h, layout, cs, data)? }; let meta = codecs::Metadata { @@ -174,6 +210,46 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Result, IoError> { + let len = pixels.checked_mul(bytes_per_pixel).ok_or_else(|| IoError::Unsupported("image too large".into()))?; + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| IoError::Unsupported(format!("not enough memory for a {} MB image", len >> 20)))?; + Ok(v) +} + +/// Remove the trailing `alpha` bytes of every `color + alpha`-byte pixel, in place. +fn drop_alpha(data: &mut Vec, color: usize, alpha: usize) { + let stride = color + alpha; + let n = data.len() / stride; + for i in 0..n { + data.copy_within(i * stride..i * stride + color, i * color); + } + data.truncate(n * color); + data.shrink_to_fit(); +} + +/// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). +fn opaque_surface(s: &Surface, r: Rect) -> bool { + let fmt = s.format(); + let (ch, bps) = (fmt.channels(), fmt.sample.bytes()); + let a = ch - 1; + let alpha_ok = |px: &[u8]| photocraft_color::read_sample(px, fmt.sample, a) >= 1.0; + let mut default = vec![0u8; ch * bps]; + photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut default); + r.tiles().all(|tc| { + let tr = tc.rect().intersect(&r); + match s.tile(tc) { + None => alpha_ok(&default), + Some(t) => (tr.y0..tr.y1).all(|y| { + let row = ((y - tc.rect().y0) as usize * TILE_SIZE as usize + (tr.x0 - tc.rect().x0) as usize) * ch * bps; + t.bytes()[row..row + tr.width() as usize * ch * bps].chunks_exact(ch * bps).all(alpha_ok) + }), + } + }) +} + /// Flattens and encodes as `format`. pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Result { if let Some(r) = export_mode_specific(doc, format, opts)? { @@ -250,15 +326,14 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> match doc.mode { ColorMode::Indexed if format == Format::Png => { let Some(table) = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256) else { return Ok(None) }; - let buf = photocraft_compose::flatten(doc); - let idx: Vec = buf - .px - .iter() - .map(|p| match table.transparent { + let mut idx = try_buffer(doc.size.area() as usize, 1)?; + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + idx.extend(band.px.iter().map(|p| match table.transparent { Some(t) if p[3] < 0.5 => t, _ => table.nearest([p[0], p[1], p[2]]) as u8, - }) - .collect(); + })); + Ok(()) + }); let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent)?; Ok(Some(ExportResult { bytes, warnings: vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())] })) } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 93e3c825d..072bd252f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -92,6 +92,7 @@ impl Ex { let mut invert = vec![self.cmyk; self.cc]; invert.push(false); let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert); + drop(bytes); let (w, h) = (r.width() as usize, r.height() as usize); // Alpha (-1) first, then the colour channels; each compressed on its own thread. let order: Vec<(i16, &Vec)> = @@ -482,6 +483,57 @@ fn guides_resource(doc: &Document) -> Vec { v } +fn cmyk_of(fmt: &PixelFormat) -> bool { + fmt.mode == ColorMode::Cmyk +} + +/// The merged image's planes (colour channels, then alpha), big-endian, matted against white +/// when `matte`; with whether any pixel needs the alpha channel and whether any is below 1. +fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec, bool, bool) { + let sample = fmt.sample; + let cc = fmt.mode.color_channels(); + let bps = sample.bytes(); + let n = doc.size.area() as usize; + let plane = n * bps; + let mut planes = vec![0u8; plane * (cc + 1)]; + let (mut has_alpha, mut translucent) = (false, false); + let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]); + let canvas = doc.bounds(); + let w = canvas.width() as usize; + let space = photocraft_compose::cmyk_space(doc); + let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + has_alpha |= band.px.iter().any(|p| q255(p[3]) < 255 || (sample == SampleType::F32 && p[3] < 1.0)); + translucent |= band.px.iter().any(|p| p[3] < 1.0); + let start = (band.rect.y0 - canvas.y0) as usize * w; + // Converted and encoded on all cores, then copied into each plane. + let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { + // The composite came through the document's CMYK profile: convert back through it too. + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut out: Vec> = vec![Vec::with_capacity(range.len() * bps); cc + 1]; + let mut v = [0.0f32; 5]; + for p in &band.px[range.clone()] { + photocraft_raster::from_rgba_into(fmt, *p, &mut v); + for c in 0..=cc { + // Matte against white like Photoshop (see `pixels::matte`). + let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] }; + let x = if cmyk && c < cc { 1.0 - m } else { m }; + encode_be(x, sample, &mut out[c]); + } + } + (range.start, out) + }) + }); + for (at, part) in parts { + for (c, src) in part.iter().enumerate() { + let o = c * plane + (start + at) * bps; + planes[o..o + src.len()].copy_from_slice(src); + } + } + Ok(()) + }); + (planes, has_alpha, translucent) +} + /// Converts a document to a PSD file model (see [`document_to_psd_with`]). pub fn document_to_psd(doc: &Document) -> PsdFile { document_to_psd_with(doc, &PsdExportOptions::default()).0 @@ -547,43 +599,16 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile } ex.emit(&doc.layers); - // Merged composite. - let comp = photocraft_compose::flatten(doc); - let n = comp.px.len(); - let has_alpha = comp.px.iter().any(|p| q255(p[3]) < 255 || (sample == SampleType::F32 && p[3] < 1.0)); - let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); - let cmyk = ex.cmyk; - // The composite came through the document's CMYK profile: convert back through it too. - let space = photocraft_compose::cmyk_space(doc); - // Converted and encoded in bands on all cores, then joined per plane. - let parts = crate::pixels::par_map(crate::pixels::bands(n), |range| { - photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut planes: Vec> = vec![Vec::with_capacity(range.len() * sample.bytes()); cc + 1]; - let mut v = [0.0f32; 5]; - for p in &comp.px[range] { - photocraft_raster::from_rgba_into(&fmt, *p, &mut v); - for c in 0..=cc { - // Matte against white like Photoshop (see `pixels::matte`). - let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] }; - let x = if cmyk && c < cc { 1.0 - m } else { m }; - encode_be(x, sample, &mut planes[c]); - } - } - planes - }) - }); - let mut color_planes: Vec> = vec![Vec::with_capacity(n * sample.bytes()); cc + 1]; - for part in parts { - for (dst, src) in color_planes.iter_mut().zip(part) { - dst.extend_from_slice(&src); - } + // Merged composite, rendered and encoded in bands (no full-size float composite). Matting + // against white only changes pixels with alpha < 1; if some are slightly translucent but all + // round to opaque (so no alpha channel is written), encode once more without the matte. + let (mut planes, has_alpha, translucent) = merged_planes(doc, &fmt, cmyk_of(&fmt), true); + if !has_alpha && translucent { + planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false).0; } - let mut planes = Vec::new(); - for p in &color_planes[..cc] { - planes.extend_from_slice(p); - } - if has_alpha { - planes.extend_from_slice(&color_planes[cc]); + let n = doc.size.area() as usize; + if !has_alpha { + planes.truncate(cc * n * sample.bytes()); } let canvas = doc.bounds(); let max_extra = 56 - cc - usize::from(has_alpha); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index cb85dc12a..c2bdc0ce3 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartObject, SmartSource, TextLayer}; -use photocraft_geom::{Rect, Size}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; use photocraft_psd::resources::ids; use photocraft_psd::tagged::BlockData; @@ -497,15 +497,22 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { let mut s = Surface::from_interleaved(fmt, canvas, &bytes); if alpha_idx.is_some() { // Undo Photoshop's white matting of the merged image. + // A band of tile rows at a time (no full-size float copy of the image). let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); - let mut vals = s.read_region(canvas); - for px in vals.chunks_exact_mut(cc + 1) { - let a = px[cc]; - for c in 0..cc { - px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; + let mut vals = Vec::new(); + let mut y = canvas.y0; + while y < canvas.y1 { + let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); + s.read_region_into(band, &mut vals); + for px in vals.chunks_exact_mut(cc + 1) { + let a = px[cc]; + for c in 0..cc { + px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; + } } + s.write_region(band, &vals); + y = band.y1; } - s.write_region(canvas, &vals); } s.prune(); let mut bg = Layer::new("Background", LayerContent::Raster(s)); diff --git a/crates/io/tests/banded.rs b/crates/io/tests/banded.rs new file mode 100644 index 000000000..cf0919c74 --- /dev/null +++ b/crates/io/tests/banded.rs @@ -0,0 +1,95 @@ +//! Exports that render the composite in bands (issue #49): results must equal the one-shot +//! composite, across band boundaries, depths and alpha. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; +use photocraft_raster::Surface; + +/// Two layers over more than one export band (a band is ~8 MP), with a gradient, so every row +/// differs; `alpha` < 1 leaves the top-right translucent. +fn layered(w: u32, h: u32, depth: SampleType, alpha: f32) -> Document { + let mut d = Document::new("b", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let mut bg = Surface::new(fmt); + let mut row = Vec::with_capacity(w as usize * 4); + for y in 0..h as i32 { + row.clear(); + for x in 0..w as i32 { + let a = if x > w as i32 / 2 && y < h as i32 / 2 { alpha } else { 1.0 }; + row.extend([x as f32 / w as f32, y as f32 / h as f32, 0.5, a]); + } + bg.write_region(Rect::new(0, y, w as i32, y + 1), &row); + } + d.layers.push(Layer::new("bg", LayerContent::Raster(bg))); + let mut top = Layer::raster("top", fmt); + top.surface_mut().unwrap().fill_rect(Rect::new(10, 10, w as i32 - 10, 40), &[1.0, 0.0, 0.0, 1.0]); + top.opacity = 0.5; + d.layers.push(top); + d +} + +fn composite_q(doc: &Document, scale: f32) -> Vec { + photocraft_compose::flatten(doc).px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).map(|v| (v.clamp(0.0, 1.0) * scale).round()).collect() +} + +#[test] +fn layered_png_export_spans_bands() { + // 3000 × 3000 = 9 MP: two bands. + for (depth, scale) in [(SampleType::U8, 255.0), (SampleType::U16, 65535.0)] { + for alpha in [1.0, 0.5] { + let d = layered(3000, 3000, depth, alpha); + let r = export(&d, "x.png", &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout().has_alpha(), alpha < 1.0, "{depth:?} alpha {alpha}"); + let got: Vec = img.to_rgba_f32().iter().map(|v| (v * scale).round()).collect(); + assert!(got == composite_q(&d, scale), "{depth:?} alpha {alpha}: pixels differ"); + } + } +} + +#[test] +fn single_layer_native_export_strips_opaque_alpha() { + for alpha in [1.0, 0.25] { + let mut d = layered(700, 300, SampleType::U16, alpha); + d.layers.truncate(1); + let r = export(&d, "x.tiff", &ExportOptions::default()).unwrap(); + let back = import("x.tiff", &r.bytes).unwrap().document; + let (a, b) = (d.layers[0].surface().unwrap(), back.layers[0].surface().unwrap()); + assert_eq!(a.read_region(d.bounds()), b.read_region(d.bounds()), "alpha {alpha}"); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout().has_alpha(), alpha < 1.0); + } +} + +#[test] +fn psd_merged_image_matches_composite_and_drops_near_opaque_alpha() { + // Alpha 0.999 rounds to 255: no alpha channel, so the merged image must not be matted. + for alpha in [1.0, 0.999, 0.5] { + let d = layered(400, 300, SampleType::U8, alpha); + let file = document_to_psd(&d); + assert_eq!(file.merged_has_alpha(), alpha < 0.99, "alpha {alpha}"); + let merged = merged_composite(&file).unwrap(); + let want = photocraft_compose::flatten(&d).px; + let m = merged.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |i| (a[i] - b[i]).abs())).fold(0.0f32, f32::max); + // Un-matting divides the 8-bit rounding error by alpha. + assert!(m <= 1.0 / (255.0 * alpha) + 1e-4, "alpha {alpha}: max diff {m}"); + } +} + +#[test] +fn flat_import_converts_rgb_in_bands() { + // An opaque RGB file becomes an RGBA document (converted in ~32 MB bands: 36 MB here). + let (w, h) = (4000u32, 3000u32); + let px: Vec = (0..w * h * 3).map(|i| (i % 251) as u8).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px.clone()).unwrap(); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let doc = import("x.png", &bytes).unwrap().document; + let s = doc.layers[0].surface().unwrap(); + assert_eq!(s.format(), PixelFormat::RGBA8); + let back = s.to_interleaved(doc.bounds()); + let rgb: Vec = back.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + assert_eq!(rgb, px); + assert!(back.as_chunks::<4>().0.iter().all(|p| p[3] == 255)); +} diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index d9cd1a3a7..9d7fa3474 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -27,6 +27,9 @@ pub struct History { undo: VecDeque, redo: Vec, pub max_states: usize, + /// Pixel memory budget in bytes for the current document plus the tiles only history holds + /// (0 = unlimited). [`History::trim`] drops the oldest states beyond it. + pub max_bytes: usize, /// Label of the step that produced the current document. current_label: String, } @@ -39,7 +42,7 @@ impl Default for History { impl History { pub fn new(max_states: usize) -> Self { - Self { undo: VecDeque::new(), redo: Vec::new(), max_states: max_states.max(1), current_label: "Open".into() } + Self { undo: VecDeque::new(), redo: Vec::new(), max_states: max_states.max(1), max_bytes: 0, current_label: "Open".into() } } /// Record that `before` was replaced by a new current document via step `label`. @@ -110,27 +113,54 @@ impl History { /// Approximate unique pixel bytes held by history (tiles not shared with `current`). pub fn unique_bytes(&self, current: &Document) -> usize { - fn collect(doc: &Document, out: &mut HashSet, bytes: &mut Vec<(usize, usize)>) { - for (_, _, l) in doc.walk() { - if let Some(s) = l.surface() { - for (_, t) in s.tiles() { - let p = Arc::as_ptr(t) as usize; - if out.insert(p) { - bytes.push((p, t.bytes().len())); - } - } - } - } - } - let mut cur = HashSet::new(); - collect(current, &mut cur, &mut Vec::new()); - let mut seen = cur.clone(); - let mut hist = Vec::new(); - for s in self.undo.iter().chain(self.redo.iter()) { - collect(&s.doc, &mut seen, &mut hist); - } - hist.iter().map(|(_, b)| b).sum() + let mut seen = HashSet::new(); + tile_bytes(current, &mut seen); + self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum() } + + /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the + /// tiles only history holds (newest states first). The oldest undo states that don't fit are + /// dropped; the most recent one is always kept so the last step can be undone. Returns how + /// many states were dropped. + pub fn trim(&mut self, current: &Document) -> usize { + if self.max_bytes == 0 || self.undo.len() <= 1 { + return 0; + } + let mut seen = HashSet::new(); + let mut total = tile_bytes(current, &mut seen); + for s in self.redo.iter().rev() { + total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); + } + let mut keep = 0; + for s in self.undo.iter().rev() { + total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); + if total > self.max_bytes && keep >= 1 { + break; + } + keep += 1; + } + let drop = self.undo.len() - keep; + self.undo.drain(..drop); + drop + } +} + +/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels) not already in `seen`. +fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { + let mut add = |s: &photocraft_doc::Surface| s.tiles().filter(|(_, t)| seen.insert(Arc::as_ptr(t) as usize)).map(|(_, t)| t.bytes().len()).sum::(); + let mut n = 0; + for (_, _, l) in doc.walk() { + if let Some(s) = l.surface() { + n += add(s); + } + if let Some(m) = &l.mask { + n += add(&m.surface); + } + } + for c in doc.channels.iter().chain(&doc.quick_mask) { + n += add(&c.surface); + } + n } #[cfg(test)] @@ -208,6 +238,33 @@ mod tests { assert_eq!(cur.name, "6"); } + #[test] + fn byte_budget_drops_oldest_states_but_keeps_one() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = 256 * 256 * 4; + // Each step repaints the background's tile: one unique tile per state. + for i in 0..6 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert_eq!(h.trim(&cur), 0, "unlimited by default"); + assert_eq!(h.past_len(), 6); + // The current tile plus three history tiles fit. + h.max_bytes = 4 * tile; + assert_eq!(h.trim(&cur), 3); + assert_eq!(h.past_len(), 3); + assert_eq!(h.unique_bytes(&cur), 3 * tile); + assert_eq!(h.entries()[0], "paint 2"); + // A budget smaller than one state still keeps the last step undoable. + h.max_bytes = 1; + h.trim(&cur); + assert_eq!(h.past_len(), 1); + assert!(h.undo(cur.clone()).is_some()); + } + #[test] fn history_shares_untouched_tiles() { let mut h = History::default(); diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index ce033063b..91c00db29 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -138,7 +138,8 @@ fn gpu() -> Option { block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) .ok()?; eprintln!("adapter: {:?}", adapter.get_info().name); - let limits = adapter.limits(); + // The limits the app requests (see `gpu_canvas::use_adapter_limits`). + let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter); let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?; let comp = photocraft_gpu::Compositor::new(&device); Some(Gpu { device, queue, comp }) diff --git a/crates/ui-egui/examples/interactive_bench.rs b/crates/ui-egui/examples/interactive_bench.rs index 45da964eb..729eec6a6 100644 --- a/crates/ui-egui/examples/interactive_bench.rs +++ b/crates/ui-egui/examples/interactive_bench.rs @@ -49,7 +49,8 @@ fn gpu() -> Option { block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) .ok()?; eprintln!("adapter: {}", adapter.get_info().name); - let limits = adapter.limits(); + // The limits the app requests (see `gpu_canvas::use_adapter_limits`). + let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter); let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?; let comp = photocraft_gpu::Compositor::new(&device); Some(Gpu { device, queue, comp }) diff --git a/crates/ui-egui/examples/large_image_bench.rs b/crates/ui-egui/examples/large_image_bench.rs new file mode 100644 index 000000000..3b22de008 --- /dev/null +++ b/crates/ui-egui/examples/large_image_bench.rs @@ -0,0 +1,172 @@ +//! Large-image benchmark (issue #49): a 14000×14000 (or `--size`) 8-bit RGB photo through open, +//! canvas refresh, panel thumbnails, a filter, a brush stroke, save and export, with the GPU +//! device created the way the app creates it (so its texture limits are the app's). +//! +//! One operation per run, so the process's peak RSS is that operation's (plus opening): +//! +//! ```sh +//! cargo build --release -p photocraft-ui-egui --example large_image_bench +//! for op in open refresh thumbs filter brush psd png jpeg pcraft; do +//! /usr/bin/time -l target/release/examples/large_image_bench --size 14000x14000 --op $op 2>&1 | grep -E 'ms|maximum resident' +//! done +//! ``` +//! +//! The source JPEG is generated once and cached (`--cache `, default the system temp dir). +//! `--op open` is the baseline every other operation includes. `--cpu` skips the GPU. + +use std::time::Instant; + +use eframe::egui_wgpu::RenderState; +use photocraft_engine::Session; +use photocraft_ui_egui::gpu_canvas::GpuCanvas; +use serde_json::{Value, json}; + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +/// A photo-like RGB image: smooth gradients, soft blobs, fine noise (so JPEG has real work). +fn photo(w: u32, h: u32) -> photocraft_codecs::Image { + use rayon::prelude::*; + let mut px = vec![0u8; w as usize * h as usize * 3]; + px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| { + for x in 0..w as usize { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5; + let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp(); + let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06; + let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06; + let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06; + for (c, v) in [r, g, b].into_iter().enumerate() { + row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + }); + photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image") +} + +/// A headless GPU canvas on a device created like the app's. +fn canvas() -> Option<(GpuCanvas, RenderState)> { + let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup(); + let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?; + let l = rs.device.limits(); + eprintln!("adapter: {} (max texture {}, max buffer {} MB)", rs.adapter.get_info().name, l.max_texture_dimension_2d, l.max_buffer_size >> 20); + Some((GpuCanvas::new(&rs), rs)) +} + +fn exec(s: &mut Session, id: &str, p: Value) { + if let Err(e) = s.execute(id, p) { + panic!("{id}: {e}"); + } +} + +/// The canvas refresh the app does after an edit (`canvas::ensure_gpu`): the damage rect, or +/// everything, then waits for the GPU (as presenting the next frame would, which also releases +/// the frame's transient GPU memory). Returns the path taken. +fn refresh(gpu: Option<&(GpuCanvas, RenderState)>, s: &Session, full: bool) -> &'static str { + let st = s.active().expect("document"); + let damage = if full { None } else { st.last_damage }; + match gpu { + Some((g, rs)) => { + let kind = g.refresh(st.doc.id.0, &st.doc, damage, None).kind; + let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None }); + kind + } + None => { + let r = damage.unwrap_or(st.doc.bounds()).intersect(&st.doc.bounds()); + std::hint::black_box(photocraft_compose::render(&st.doc, r)); + "cpu" + } + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let (w, h) = + arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(14000), b.parse().unwrap_or(14000)))).unwrap_or((14000, 14000)); + let op = arg(&args, "--op").unwrap_or_else(|| "open".into()); + let dir = arg(&args, "--cache").map_or_else(std::env::temp_dir, std::path::PathBuf::from); + let src = dir.join(format!("photocraft-bench-{w}x{h}.jpg")); + if !src.exists() { + let t = Instant::now(); + let img = photo(w, h); + let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).expect("jpeg"); + std::fs::write(&src, jpeg).expect("write cache"); + eprintln!("generated {} in {:.0} ms (run again to measure)", src.display(), ms(t)); + return; + } + let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() }; + let gpu = gpu.as_ref(); + println!("document {w}×{h} ({:.0} MP), op {op}", (w as f64 * h as f64) / 1e6); + + let t = Instant::now(); + let bytes = std::fs::read(&src).expect("read"); + let doc = photocraft_io::import("photo.jpg", &bytes).expect("import").document; + drop(bytes); + let mut s = Session::new(); + s.open_document(doc, Some("photo.jpg".into())); + let t_open = ms(t); + let t = Instant::now(); + let path = refresh(gpu, &s, true); + println!("open (decode + document) {t_open:>9.0} ms"); + println!("first refresh ({path:<8}) {:>9.0} ms", ms(t)); + + let doc = || s.active().expect("doc").doc.clone(); + match op.as_str() { + "open" => {} + "refresh" => { + for _ in 0..3 { + let t = Instant::now(); + let path = refresh(gpu, &s, true); + println!("full refresh ({path:<8}) {:>9.0} ms", ms(t)); + } + } + "thumbs" => { + let d = doc(); + let t = Instant::now(); + std::hint::black_box(photocraft_compose::thumbnail(&d, 512)); + println!("navigator thumbnail (512) {:>9.0} ms", ms(t)); + let t = Instant::now(); + std::hint::black_box(photocraft_compose::thumbnail(&d, 56)); + println!("channel thumbnails (56) {:>9.0} ms", ms(t)); + let t = Instant::now(); + std::hint::black_box(photocraft_compose::thumbnail(&d, 384)); + println!("histogram source (384) {:>9.0} ms", ms(t)); + } + "filter" => { + let t = Instant::now(); + exec(&mut s, "filter.blur.gaussianBlur", json!({"radius": 10.0})); + let t1 = ms(t); + let t = Instant::now(); + let path = refresh(gpu, &s, false); + println!("Gaussian Blur r 10 {t1:>9.0} ms"); + println!(" refresh ({path:<8}) {:>9.0} ms", ms(t)); + } + "brush" => { + exec(&mut s, "layer.new.layer", json!({"name": "paint"})); + refresh(gpu, &s, true); + for k in 0..3 { + let (x, y) = (300 + k * 2500, 300 + k * 1500); + let pts: Vec = (0..40).map(|i| json!([x + i * 30, y + ((i as f32 * 0.4).sin() * 80.0) as i32])).collect(); + let t = Instant::now(); + exec(&mut s, "paint.stroke", json!({"points": pts, "size": 120, "hardness": 0.5, "color": "#2040c0", "spacing": 0.25})); + let t1 = ms(t); + let t = Instant::now(); + let path = refresh(gpu, &s, false); + println!("brush stroke 40 dabs {t1:>9.0} ms refresh ({path}) {:>6.0} ms", ms(t)); + } + } + "psd" | "png" | "jpeg" | "pcraft" => { + let ext = if op == "jpeg" { "jpg" } else { op.as_str() }; + let d = doc(); + let t = Instant::now(); + let out = photocraft_io::export(&d, &format!("x.{ext}"), &Default::default()).expect("export"); + println!("save {ext:<6} ({:>5} MB) {:>9.0} ms", out.bytes.len() >> 20, ms(t)); + } + other => eprintln!("unknown --op {other}"), + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45714cac5..1c5b472fe 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -138,37 +138,6 @@ fn canvas_display(app: &PhotocraftApp, doc: &Document) -> (Option photocraft_compose::Buffer { - let (w, h) = (buf.rect.width(), buf.rect.height()); - let (nw, nh) = ((w / factor).max(1), (h / factor).max(1)); - let mut out = photocraft_compose::Buffer::transparent(DRect::from_xywh(0, 0, nw, nh)); - for y in 0..nh { - for x in 0..nw { - let mut acc = [0.0f32; 4]; - let mut n = 0.0; - for dy in 0..factor { - for dx in 0..factor { - let (sx, sy) = (x * factor + dx, y * factor + dy); - if sx < w && sy < h { - let p = buf.px[(sy * w + sx) as usize]; - // premultiplied average - acc[0] += p[0] * p[3]; - acc[1] += p[1] * p[3]; - acc[2] += p[2] * p[3]; - acc[3] += p[3]; - n += 1.0; - } - } - } - let a = acc[3] / n; - let px = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], a] } else { [0.0; 4] }; - out.px[(y * nw + x) as usize] = px; - } - } - out -} - /// The document to render: the committed one, or a clone with the live adjustment preview applied. fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc, u64) { let st = &app.session.documents()[idx]; @@ -219,6 +188,41 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc (st.doc.clone(), 0) } +/// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI). +pub const NAVIGATOR_SIDE: u32 = 512; + +/// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture +/// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document +/// doesn't also pay a full-resolution CPU composite for the navigator. +pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { + if app.gpu.is_none() { + return ensure_texture(app, ctx, idx).map(|(t, _)| t); + } + let (revision, id) = app.session.documents().get(idx).map(|st| (st.revision, st.doc.id))?; + let (doc, preview_key) = display_doc(app, idx); + let (display, display_key) = canvas_display(app, &doc); + let preview_key = preview_key ^ display_key; + let key = egui::Id::new(("navigator", id.0)); + let cached: Option<(u64, u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key)); + if let Some((r, p, t)) = &cached + && (*r, *p) == (revision, preview_key) + { + return Some(t.id()); + } + let t0 = crate::gpu_canvas::now_ms(); + let image = display_image(display.as_deref(), &photocraft_compose::thumbnail_buffer(&doc, NAVIGATOR_SIDE)); + let tex = match cached { + Some((_, _, mut t)) => { + t.set(image, TextureOptions::LINEAR); + t + } + None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR), + }; + app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0); + ctx.data_mut(|d| d.insert_temp(key, (revision, preview_key, tex.clone()))); + Some(tex.id()) +} + /// Make sure the canvas texture for document `idx` is current; returns (texture id, scale). pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option<(egui::TextureId, f32)> { let (revision, last_damage, id) = { @@ -255,12 +259,13 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); } } else { - let full = photocraft_compose::flatten(&doc); - let t1 = crate::gpu_canvas::now_ms(); let longest = doc.size.width.max(doc.size.height); let factor = longest.div_ceil(MAX_TEXTURE).max(1); - let d = display.as_deref(); - let (img, scale) = if factor > 1 { (display_image(d, &downsample(&full, factor)), 1.0 / factor as f32) } else { (display_image(d, &full), 1.0) }; + // Reduced in bands straight from the compositor: no full-size composite in memory. + let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1)); + let full = photocraft_compose::render_reduced(&doc, w, h); + let t1 = crate::gpu_canvas::now_ms(); + let (img, scale) = (display_image(display.as_deref(), &full), 1.0 / factor as f32); match cache.texture.as_mut() { Some(t) if t.size() == img.size => t.set(img, TextureOptions::LINEAR), _ => cache.texture = Some(ctx.load_texture(format!("canvas-{}", id.0), img, TextureOptions::LINEAR)), @@ -308,7 +313,6 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool { let (doc, preview_key) = display_doc(app, idx); let (display, display_key) = canvas_display(app, &doc); let preview_key = preview_key ^ display_key; - let encode_srgb = display.as_ref().is_some_and(|d| d.encode_srgb); let size = [doc.size.width, doc.size.height]; let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, @@ -323,44 +327,18 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool { if present && cache.revision == revision && cache.preview_key == preview_key { return true; } - let t0 = crate::gpu_canvas::now_ms(); let partial = present && cache.preview_key == preview_key && cache.revision + 1 == revision && last_damage.is_some(); - let mut done = false; - // Preferred: the wgpu compositor renders straight into the display texture (damage rect for - // strokes, everything otherwise). Falls back to the CPU compositor below when unsupported. - let region = if partial { last_damage.unwrap_or(doc.bounds()).intersect(&doc.bounds()) } else { doc.bounds() }; - match gpu.composite(&doc, region, encode_srgb) { - Ok(stats) => { - done = true; - let kind = if partial { "gpu-rect" } else { "gpu-full" }; - app.perf.record(kind, region.width() as u64 * region.height() as u64, crate::gpu_canvas::now_ms() - t0, 0.0); - app.perf.gpu_uploads = stats.tiles_uploaded as u64; - } - Err(e) => { - if app.perf.gpu_fallback.as_deref() != Some(e.0.as_str()) { - log::info!("{e}; using the CPU compositor"); - } - app.perf.gpu_fallback = Some(e.0); - } + let r = gpu.refresh(id.0, &doc, if partial { last_damage } else { None }, display.as_deref()); + if let Some(e) = &r.fallback + && app.perf.gpu_fallback.as_deref() != Some(e.as_str()) + { + log::info!("{e}; using the CPU compositor"); } - if partial && !done { - let r = last_damage.unwrap_or(DRect::EMPTY).intersect(&doc.bounds()); - if r.is_empty() { - done = true; - } else { - let buf = photocraft_compose::render(&doc, r); - let t1 = crate::gpu_canvas::now_ms(); - done = gpu.upload_buffer_rect(id.0, &texture_buffer(display.as_deref(), &buf)); - app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); - } - } - if !done { - // CPU fallback (documents the GPU compositor doesn't cover yet, e.g. layer effects). - // TODO: render only the visible region at display resolution when zoomed out. - let full = photocraft_compose::flatten(&doc); - let t1 = crate::gpu_canvas::now_ms(); - gpu.upload_buffer_full(id.0, &texture_buffer(display.as_deref(), &full)); - app.perf.record("full", size[0] as u64 * size[1] as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); + app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if r.kind.starts_with("gpu") { + app.perf.gpu_uploads = r.uploads; + } else { + app.perf.gpu_fallback = r.fallback; } let Some(cache) = app.canvases.get_mut(&id) else { return true }; cache.revision = revision; @@ -1689,16 +1667,6 @@ mod tests { assert_eq!(zoom_step(32.0, 1), 32.0); } - #[test] - fn downsample_averages_premultiplied() { - let mut b = photocraft_compose::Buffer::transparent(DRect::new(0, 0, 2, 2)); - b.px[0] = [1.0, 0.0, 0.0, 1.0]; - let d = downsample(&b, 2); - assert_eq!(d.px.len(), 1); - let p = d.px[0]; - assert!((p[0] - 1.0).abs() < 1e-6 && (p[3] - 0.25).abs() < 1e-6, "{p:?}"); - } - #[test] fn damage_grows_by_nested_effect_reach() { use photocraft_doc::{Effect, Layer}; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index c14bc1536..bebd3e2b9 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -248,6 +248,94 @@ impl GpuCanvas { result } + /// Bring document `key`'s display texture up to date with `doc`: only `damage` when given (a + /// brush stroke on an already uploaded document), else everything. The wgpu compositor renders + /// straight into the texture when it supports the document; otherwise the CPU compositor does. + /// `display` is the canvas colour transform: the GPU path stores the sRGB encoding when it + /// asks for it, and every CPU band or rect goes through its `texture_buffer`. + pub fn refresh( + &self, + key: u64, + doc: &photocraft_doc::Document, + damage: Option, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + ) -> Refresh { + let bounds = doc.bounds(); + let size = [doc.size.width, doc.size.height]; + let damage = damage.filter(|_| self.has(key, size)).map(|r| r.intersect(&bounds)); + let t0 = now_ms(); + let mut out = Refresh::default(); + let region = damage.unwrap_or(bounds); + let encode_srgb = display.is_some_and(|d| d.encode_srgb); + let gpu = if key == doc.id.0 { self.composite(doc, region, encode_srgb) } else { Err(photocraft_gpu::Unsupported("preview texture".into())) }; + match gpu { + Ok(stats) => { + out.kind = if damage.is_some() { "gpu-rect" } else { "gpu-full" }; + out.px = region.width() as u64 * region.height() as u64; + out.composite_ms = now_ms() - t0; + out.uploads = stats.tiles_uploaded as u64; + return out; + } + Err(e) => out.fallback = Some(e.0), + } + if let Some(r) = damage { + if r.is_empty() { + out.kind = "rect"; + return out; + } + let buf = photocraft_compose::render(doc, r); + let t1 = now_ms(); + if self.upload_buffer_rect(key, &texture_buffer(display, &buf)) { + out.kind = "rect"; + out.px = r.width() as u64 * r.height() as u64; + out.composite_ms = t1 - t0; + out.upload_ms = now_ms() - t1; + return out; + } + } + self.upload_composite(key, doc, display); + out.kind = "full"; + out.px = bounds.width() as u64 * bounds.height() as u64; + out.composite_ms = now_ms() - t0; + out + } + + /// Composite the whole of `doc` with the CPU compositor into document `key`'s texture, band by + /// band (each band converted and uploaded before the next is rendered), so a huge document + /// never needs a full-size float composite or RGBA8 copy in memory. + pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { + let size = [doc.size.width, doc.size.height]; + if size[0] == 0 || size[1] == 0 { + return; + } + let (device, queue) = (&self.rs.device, &self.rs.queue); + { + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + if res.docs.get(&key).is_none_or(|d| d.size != size) { + let tex = DocTextures::new(device, res, size, self.tile); + res.docs.insert(key, tex); + } + } + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + let rgba = premultiply_rgba8(&texture_buffer(display, &band).px); + let renderer = self.rs.renderer.read(); + let d = renderer.callback_resources.get::().and_then(|r| r.docs.get(&key)).ok_or(())?; + d.write(queue, [0, band.rect.y0 as u32], [size[0], band.rect.height()], size[0], &rgba); + drop(renderer); + // Flush the staged band so its staging memory is reclaimed while the next renders. + queue.submit(std::iter::empty::()); + let _ = device.poll(wgpu::PollType::Poll); + Ok(()) + }); + let mut renderer = self.rs.renderer.write(); + if let Some(res) = renderer.callback_resources.get_mut::() + && let Some(d) = res.docs.get(&key) + { + d.regenerate_mips(device, queue, res, [0, 0, size[0], size[1]]); + } + } + /// Free textures of documents not in `live`. pub fn retain(&self, live: &[u64]) { let mut renderer = self.rs.renderer.write(); @@ -289,6 +377,61 @@ impl GpuCanvas { } } +/// A CPU composite as the canvas texture stores it (`display`'s encoding; as is without one). +fn texture_buffer<'a>( + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + buf: &'a photocraft_compose::Buffer, +) -> std::borrow::Cow<'a, photocraft_compose::Buffer> { + match display { + Some(d) => d.texture_buffer(buf), + None => std::borrow::Cow::Borrowed(buf), + } +} + +/// What a [`GpuCanvas::refresh`] did. +#[derive(Clone, Debug, Default)] +pub struct Refresh { + /// "gpu-rect", "gpu-full" (wgpu compositor), "rect" or "full" (CPU compositor). + pub kind: &'static str, + /// Pixels refreshed. + pub px: u64, + pub composite_ms: f64, + pub upload_ms: f64, + /// Layer tiles the wgpu compositor uploaded. + pub uploads: u64, + /// Why the wgpu compositor wasn't used. + pub fallback: Option, +} + +/// How the app creates its wgpu device: egui's defaults, except that the texture and buffer size +/// limits are the adapter's own rather than egui's 8192 px cap, so large documents stay on the +/// GPU compositor. +pub fn wgpu_setup() -> egui_wgpu::WgpuSetup { + let mut setup = egui_wgpu::WgpuSetup::without_display_handle(); + use_adapter_limits(&mut setup); + setup +} + +/// Make `setup` request the adapter's own texture and buffer size limits instead of egui's +/// default 8192 px cap (which sent every document wider or taller than 8192 px to the CPU +/// compositor). Everything else (features, the WebGL2 base limits on GL) stays egui's default. +pub fn use_adapter_limits(setup: &mut egui_wgpu::WgpuSetup) { + if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { + create.device_descriptor = std::sync::Arc::new(|adapter| wgpu::DeviceDescriptor { + label: Some("photocraft wgpu device"), + required_limits: device_limits(adapter), + ..Default::default() + }); + } +} + +/// egui's base limits (WebGL2's on GL) with the adapter's maximum texture and buffer sizes. +pub fn device_limits(adapter: &wgpu::Adapter) -> wgpu::Limits { + let base = if adapter.get_info().backend == wgpu::Backend::Gl { wgpu::Limits::downlevel_webgl2_defaults() } else { wgpu::Limits::default() }; + let a = adapter.limits(); + wgpu::Limits { max_texture_dimension_2d: a.max_texture_dimension_2d, max_buffer_size: a.max_buffer_size, ..base } +} + /// Milliseconds since first call (monotonic). Always 0 on wasm, where `std::time` is unavailable. pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9cc888c5b..0571f171f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -898,7 +898,7 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let ctx = ui.ctx().clone(); - let Some((tex, _)) = crate::canvas::ensure_texture(app, &ctx, idx) else { return }; + let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return }; let size = app.session.documents()[idx].doc.size; let avail = ui.available_width(); let box_h = 150.0; diff --git a/crates/ui-egui/src/proxy.rs b/crates/ui-egui/src/proxy.rs index 7351a4106..35d861098 100644 --- a/crates/ui-egui/src/proxy.rs +++ b/crates/ui-egui/src/proxy.rs @@ -2,9 +2,7 @@ //! a 36 MP image). Built once per document revision; only adjustment parameters change while the //! slider moves, so the proxy's pixels stay valid and each preview frame composites ~1/k² pixels. -use photocraft_doc::{Document, Layer, LayerContent, Size}; -use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_doc::Document; /// Target pixel count for previews (fast enough to composite every frame on CPU). pub const PREVIEW_PIXELS: u64 = 2_500_000; @@ -18,67 +16,13 @@ pub fn factor(doc: &Document) -> u32 { ((px as f64 / PREVIEW_PIXELS as f64).sqrt().ceil() as u32).max(2) } -/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). -pub fn downsample(s: &Surface, k: u32) -> Surface { - let mut out = Surface::with_default(s.format(), &s.default_pixel()); - let b = s.content_bounds(); - if b.is_empty() || k <= 1 { - return if k <= 1 { s.clone() } else { out }; - } - let k = k as i32; - let bpp = s.format().bytes_per_pixel(); - let (x0, x1) = (b.x0.div_euclid(k), (b.x1 + k - 1).div_euclid(k)); - let w = (x1 - x0).max(0) as usize; - let mut row = vec![0u8; w * bpp]; - for oy in b.y0.div_euclid(k)..(b.y1 + k - 1).div_euclid(k) { - let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); - for i in 0..w { - row[i * bpp..(i + 1) * bpp].copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); - } - out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); - } - out -} - -fn shrink_layer(l: &mut Layer, k: u32) { - if let Some(m) = &mut l.mask { - m.surface = downsample(&m.surface, k); - } - if let Some(fc) = &mut l.fill_cache { - fc.surface = downsample(&fc.surface, k); - } - match &mut l.content { - LayerContent::Raster(s) => *s = downsample(s, k), - LayerContent::Group(g) => { - for c in &mut g.children { - shrink_layer(c, k); - } - } - LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} - } -} - -/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). -pub fn proxy_document(doc: &Document, k: u32) -> Document { - let mut p = doc.clone(); - if k <= 1 { - return p; - } - p.size = Size::new(doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)); - for l in &mut p.layers { - shrink_layer(l, k); - } - p.selection = None; - p -} +pub use photocraft_compose::proxy::{downsample, proxy_document}; #[cfg(test)] mod tests { use super::*; - use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + use photocraft_color::{Color, ColorMode, SampleType}; + use photocraft_doc::Size; #[test] fn factor_scales_with_size() { @@ -90,17 +34,6 @@ mod tests { assert!(big.size.area() / (k as u64 * k as u64) <= PREVIEW_PIXELS); } - #[test] - fn downsample_picks_every_kth_pixel() { - let mut s = Surface::new(PixelFormat::RGBA8); - s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); - s.write_pixel(4, 4, &[0.0, 0.0, 1.0, 1.0]); - let d = downsample(&s, 4); - assert_eq!(d.pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]); - assert_eq!(d.pixel(1, 1), vec![0.0, 0.0, 1.0, 1.0]); - assert_eq!(d.pixel(2, 2)[3], 0.0); - } - #[test] fn proxy_keeps_structure_and_ids() { let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/docs/development.md b/docs/development.md index a383e974a..dd983c1b1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -176,7 +176,7 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par ## Performance notes - The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect. -- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU). Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path). +- **The canvas composites on the GPU** (`photocraft-gpu`, driven from `gpu_canvas.rs`), layer effects included. What the planner can't express returns `Unsupported` and the canvas falls back to the CPU compositor (`photocraft-compose`, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (`compose::masks` for vector masks, `compose::shape_split` for stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths: `cargo run --release -p photocraft-ui-egui --example interactive_bench`; effects: `--example fx_bench` (`--compare files…` for GPU vs CPU); large documents (open, refresh, thumbnails, a filter, a stroke, saves; one operation per run so `/usr/bin/time -l` gives its peak memory): `--example large_image_bench -- --size 14000x14000 --op psd`. The app requests the adapter's own texture limit (egui's default is 8192 px; see `gpu_canvas::use_adapter_limits`); beyond it the CPU fallback composites and uploads in bands (`compose::render_bands`), and exports, thumbnails and flattening stream bands too, so no full-size float composite is ever held. Rendering fidelity: `cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd` (the whole PSD oracle table in seconds). `ui.inspect` → `perf.timings.gpuFallback` names the reason (`null` on the GPU path). - **Layer effects on the GPU** (`gpu/src/fx.rs`, kernels in `gpu/src/compose.wgsl`). Every enabled effect becomes a *map program* over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroring `compose::effects` step by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (`compose::layer_shape`) and its distance fields (`compose::effects::distance_field`: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budget `gpu::FX_BUDGET` (1.5 GB, least recently drawn layers evicted first). `PHOTOCRAFT_FX_TRACE=1` prints the CPU time of each rebuild. - The canvas grows a stroke's damage rect by the effect reach of the layers around it (`canvas::effect_reach`), so effects beyond the dab refresh too (on both paths). - **Numbers** (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro; `cargo run --release -p photocraft-ui-egui --example fx_bench`), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms. `fx_bench --compare corpus/psd/…/*.psd` reports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255).