Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71)

* Compose: render in place and in bands; area-reduced and proxy thumbnails

render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.

New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).

thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail

egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.

The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* IO: banded export and import; no full-size copies in encoders; larger decode limits

- Flat export renders the composite in bands and quantises each band; alpha
  is dropped in place when every pixel is opaque. The single-layer native
  path copies surface rows a band at a time (no f32 read_region and no
  per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
  flatten, no second plane copy); near-opaque documents are re-encoded
  without the white matte so the output is unchanged. Layer interleaved
  bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
  the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
  instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
  was refused at 2^28).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Filters: write tile results in memory-bounded groups

apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* History: bound pixel memory by Preferences > Performance > Memory Usage

History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves

One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-04 16:45:17 -04:00
1 parent 5716c2f6f1
commit d3b540234c
35 files changed
+1226 -344

No files matched your search

+1
View File
@@ -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
{
+3 -1
View File
@@ -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,
+21 -7
View File
@@ -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<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
};
let threads = rayon::current_num_threads();
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = 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::<f32>();
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<f32>)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = 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)]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -87,7 +87,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, 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 {
+1 -1
View File
@@ -153,7 +153,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, 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),
+3 -3
View File
@@ -97,7 +97,7 @@ fn is_latin1_keyword(k: &str) -> bool {
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, 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<Ve
s => 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());
}
+1 -1
View File
@@ -241,7 +241,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
}
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, 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();
+1 -1
View File
@@ -360,7 +360,7 @@ where
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, 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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
if !opts.webp_lossless {
return Err(CodecError::unsupported(F, "lossy WebP encoding needs libwebp (C); only lossless is available"));
}
let img = src.convert(plan.layout, plan.sample);
let img = src.converted(plan.layout, plan.sample);
let ct = match img.layout() {
ChannelLayout::Gray => image_webp::ColorType::L8,
ChannelLayout::GrayA => image_webp::ColorType::La8,
+6
View File
@@ -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<u8> {
self.convert(ChannelLayout::Rgba, SampleType::U8).data
+4 -1
View File
@@ -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 }
}
}
+193 -49
View File
@@ -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<Buffer> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let parts: Vec<Buffer> = 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<Buffer> = 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<std::sync::Arc<photocraft_color::con
photocraft_color::convert::CmykSpace::for_profile(doc.icc_profile.as_ref())
}
/// Pixels per band of [`render_bands`] (a 14000 px wide band is ~600 rows, ~130 MB of f32).
pub const BAND_PIXELS: u64 = 8 << 20;
/// Composite `rect` in horizontal bands, top to bottom, handing each band to `sink` (tiles inside
/// a band render in parallel). Peak memory is one band instead of the whole composite, so huge
/// documents can be exported or displayed without a full-size float copy. `band_rows` of 0 picks
/// about [`BAND_PIXELS`] per band; bands are whole multiples of [`RENDER_TILE`] rows.
pub fn render_bands<E>(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`.
+114
View File
@@ -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));
}
}
+114
View File
@@ -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));
}
+1
View File
@@ -181,6 +181,7 @@ fn compound<R>(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)
}
+1 -3
View File
@@ -586,12 +586,10 @@ fn build() -> Vec<CommandSpec> {
}),
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<f32> = 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(())
+1 -7
View File
@@ -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<f32> = 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<Value> {
+1 -1
View File
@@ -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 ----------
+2
View File
@@ -270,6 +270,7 @@ impl Session {
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> 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;
+13 -1
View File
@@ -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);
+118 -43
View File
@@ -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<ImportResult, IoError> {
let img = codecs::decode(bytes)?;
@@ -42,9 +45,21 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
};
let (w, h) = img.dimensions();
let mut doc = Document::new(name, Size::new(w, h), mode, depth);
let conv = img.convert(target_layout, csample);
let fmt = PixelFormat::new(mode, depth, true);
let mut s = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), conv.data());
let mut s = Surface::new(fmt);
if img.layout() == target_layout && img.sample_type() == csample {
s.write_interleaved(Rect::new(0, 0, w as i32, h as i32), img.data());
} else {
// Converted a band of rows at a time: no second full-size copy of the image.
let row = img.data().len() / (h.max(1) as usize);
let band = (BAND_BYTES / row.max(1)).max(1);
for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() {
let n = (rows.len() / row.max(1)) as u32;
let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec())?.convert(target_layout, csample);
let y0 = (i * band) as i32;
s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data());
}
}
s.prune();
let mut bg = Layer::new("Background", LayerContent::Raster(s));
if !img.layout().has_alpha() {
@@ -111,18 +126,32 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<I
let native = single_layer(doc).filter(|_| fmt.mode != ColorMode::Lab);
let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
let img = if let Some(s) = native {
// Native path: keep model and depth.
let vals = s.read_region(canvas);
// Native path: keep model and depth. The surface's encoded samples are the codec's raw
// native-endian samples, copied a band of rows at a time (alpha dropped when opaque).
let ch = fmt.channels();
let opaque = vals.chunks_exact(ch).all(|p| p[ch - 1] >= 1.0);
let opaque = opaque_surface(s, canvas);
let layout = layout_for(fmt.mode, !opaque);
let data: Vec<f32> = 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<String>) -> Result<I
warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode));
icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
}
let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque);
// Quantised straight from the composite in bands on all cores (the same rounding as
// `Image::from_normalized`, without a full-size f32 copy).
let cs = csample(fmt.sample);
let parts = crate::pixels::par_map(crate::pixels::bands(n), |range| {
let mut out = Vec::with_capacity(range.len() * layout.channels() * 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 &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<String>) -> Result<I
Ok(img.with_icc(icc).with_meta(meta))
}
/// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort)
/// when that much memory can't be had.
fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result<Vec<u8>, 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<u8>, 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<ExportResult, IoError> {
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<u8> = 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())] }))
}
+61 -36
View File
@@ -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<u8>)> =
@@ -482,6 +483,57 @@ fn guides_resource(doc: &Document) -> Vec<u8> {
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<u8>, 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<u8>> = 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<u8>> = 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<u8>> = 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);
+14 -7
View File
@@ -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<String>) {
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));
+95
View File
@@ -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<f32> {
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<f32> = 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<u8> = (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<u8> = 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));
}
+78 -21
View File
@@ -27,6 +27,9 @@ pub struct History {
undo: VecDeque<HistoryState>,
redo: Vec<HistoryState>,
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<usize>, 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>) -> 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::<usize>();
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();
+2 -1
View File
@@ -138,7 +138,8 @@ fn gpu() -> Option<Gpu> {
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 })
+2 -1
View File
@@ -49,7 +49,8 @@ fn gpu() -> Option<Gpu> {
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 })
@@ -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 <dir>`, 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<String> {
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<String> = 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<Value> = (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}"),
}
}
+50 -82
View File
@@ -138,37 +138,6 @@ fn canvas_display(app: &PhotocraftApp, doc: &Document) -> (Option<std::sync::Arc
}
}
/// Box-downsample a composite for display.
fn downsample(buf: &photocraft_compose::Buffer, factor: u32) -> 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<Document>, u64) {
let st = &app.session.documents()[idx];
@@ -219,6 +188,41 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
(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<egui::TextureId> {
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};
+143
View File
@@ -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<photocraft_geom::Rect>,
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::<Resources>() 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::<Resources>().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::<wgpu::CommandBuffer>());
let _ = device.poll(wgpu::PollType::Poll);
Ok(())
});
let mut renderer = self.rs.renderer.write();
if let Some(res) = renderer.callback_resources.get_mut::<Resources>()
&& 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<String>,
}
/// 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"))]
+1 -1
View File
@@ -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;
+4 -71
View File
@@ -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);
+1 -1
View File
@@ -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).