mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
5716c2f6f1
commit
d3b540234c
35 files changed
+1226
-344
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user