diff --git a/README.md b/README.md index 7a73e591..6f1b552d 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,15 @@ By the artcraft team An open-source, native image editor written in Rust, aiming for Photoshop-level capability and ergonomics. +

+ Photocraft editing Hokusai's The Great Wave: a caption card with a drop shadow, type layers, Vibrance and Curves adjustment layers, and the Curves editor with its histogram +
+ Artwork: The Great Wave off Kanagawa, Katsushika Hokusai, c. 1831 (public domain, via Wikimedia Commons). +

+ - **Native and portable:** egui on wgpu (Metal/Vulkan/DX12/WebGPU), for macOS, Windows, Linux and the web. Mobile shells come later. - **Photoshop-grade formats:** a standalone PSD/PSB crate (byte-exact round trips on a real-file corpus), plus 13 raster formats with symmetric read/write at 8/16/32-bit. -- **Engine first:** every action is a command, so the UI, CLI and automation (a JSON control channel, MCP next) all drive the same core. +- **Engine first:** every action is a command, so the UI, CLI and automation (a JSON control channel and an MCP server) all drive the same core. ```sh cargo run --release -p photocraft -- image.psd @@ -15,3 +21,32 @@ cargo test --workspace ``` Start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/). + +## The artcraft suite + +Open-source, clean-room, pure-Rust creative apps that share the same conventions: native on macOS, Windows and Linux, in the browser via WebAssembly, and fully drivable by agents. + + + + + + + + + +
+

📷 PhotoCraft

+ Layered image editing and compositing: the Photoshop workflow. +
+

✏️ DrawCraft

+ Vector illustration: the Illustrator workflow. +
+

🎬 FilmCraft

+ Non-linear video editing: the Premiere Pro workflow. +
+

🌄 LightCraft

+ Photo library and non-destructive raw developer: the Lightroom workflow. +
+

📄 PrintCraft

+ PDF viewing and editing: the Acrobat Pro workflow. +
diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 0051975b..06fca148 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -67,8 +67,112 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo } pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { - let k = gaussian_kernel(radius); - conv_sep(src, out, &k, &k, ctx.alpha) + // Exact kernel for small radii; beyond that three box passes approximate the Gaussian with + // cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). + if radius <= 4.0 { + let k = gaussian_kernel(radius); + return conv_sep(src, out, &k, &k, ctx.alpha); + } + gaussian_boxes(src, out, ctx.alpha, radius) +} + +/// Box widths (odd) whose `n`-fold convolution has standard deviation `sigma`. +fn boxes_for_gauss(sigma: f32, n: usize) -> Vec { + let nf = n as f32; + let w_ideal = (12.0 * sigma * sigma / nf + 1.0).sqrt(); + let mut wl = w_ideal.floor() as i32; + if wl % 2 == 0 { + wl -= 1; + } + let wl = wl.max(1); + let wu = wl + 2; + let m_ideal = (12.0 * sigma * sigma - nf * (wl * wl) as f32 - 4.0 * nf * wl as f32 - 3.0 * nf) / (-4.0 * wl as f32 - 4.0); + let m = m_ideal.round().clamp(0.0, nf) as usize; + (0..n).map(|i| if i < m { wl as usize } else { wu as usize }).collect() +} + +/// Running-sum box blur of every row of an interleaved `w × h × n` buffer (edges clamped). +fn box_rows(buf: &mut [f32], w: usize, n: usize, r: usize) { + if r == 0 || w == 0 { + return; + } + let norm = 1.0 / (2 * r + 1) as f32; + let blur_row = |row: &mut [f32]| { + let src = row.to_vec(); + let at = |x: isize, c: usize| src[(x.clamp(0, w as isize - 1) as usize) * n + c]; + for c in 0..n { + let mut acc: f32 = (-(r as isize)..=r as isize).map(|x| at(x, c)).sum(); + for x in 0..w { + row[x * n + c] = acc * norm; + acc += at(x as isize + r as isize + 1, c) - at(x as isize - r as isize, c); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + buf.par_chunks_mut(w * n).for_each(blur_row); + } + #[cfg(target_arch = "wasm32")] + buf.chunks_mut(w * n).for_each(blur_row); +} + +fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec { + let mut out = vec![0.0f32; buf.len()]; + const B: usize = 32; // cache-friendly blocks + for by in (0..h).step_by(B) { + for bx in (0..w).step_by(B) { + for y in by..(by + B).min(h) { + for x in bx..(bx + B).min(w) { + let (s, d) = ((y * w + x) * n, (x * h + y) * n); + out[d..d + n].copy_from_slice(&buf[s..s + n]); + } + } + } + } + out +} + +/// Gaussian blur by three box passes per axis over a premultiplied window. +fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec { + let n = src.ch; + let boxes = boxes_for_gauss(sigma, 3); + let margin: i32 = boxes.iter().map(|w| (*w as i32 - 1) / 2).sum::() + 1; + let win = Rect::new(out.x0 - margin, out.y0 - margin, out.x1 + margin, out.y1 + margin); + let (ww, wh) = (win.width() as usize, win.height() as usize); + let mut p = vec![0.0f32; ww * wh * n]; + let fill = |yy: usize, row: &mut [f32]| { + let y = win.y0 + yy as i32; + for (xx, px) in row.chunks_exact_mut(n).enumerate() { + for (c, v) in px.iter_mut().enumerate() { + *v = src.get(win.x0 + xx as i32, y, c); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + p.par_chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row)); + } + #[cfg(target_arch = "wasm32")] + p.chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row)); + premultiply(&mut p, n, alpha); + for w in &boxes { + box_rows(&mut p, ww, n, (w - 1) / 2); + } + let mut t = transpose(&p, ww, wh, n); + for w in &boxes { + box_rows(&mut t, wh, n, (w - 1) / 2); + } + let p = transpose(&t, wh, ww, n); + let (ow, oh) = (out.width() as usize, out.height() as usize); + let mut res = vec![0.0f32; ow * oh * n]; + for y in 0..oh { + let s = ((y + margin as usize) * ww + margin as usize) * n; + res[y * ow * n..(y + 1) * ow * n].copy_from_slice(&p[s..s + ow * n]); + } + unpremultiply(&mut res, n, alpha); + res } pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { @@ -192,3 +296,29 @@ pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: } res } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn box_gaussian_matches_exact_kernel() { + // A hard vertical edge (premultiplied RGBA-ish, 2 channels) blurred with σ = 12. + let src_rect = Rect::new(-60, -60, 180, 68); + let mut img = Image::new(src_rect, 2); + for y in src_rect.y0..src_rect.y1 { + for x in src_rect.x0..src_rect.x1 { + let i = ((y - src_rect.y0) as usize * src_rect.width() as usize + (x - src_rect.x0) as usize) * 2; + img.data[i] = if x < 60 { 1.0 } else { 0.0 }; + img.data[i + 1] = 1.0; + } + } + let out = Rect::new(0, 0, 120, 8); + let k = gaussian_kernel(12.0); + let exact = conv_sep(&img, out, &k, &k, true); + let fast = gaussian_boxes(&img, out, true, 12.0); + let err = exact.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(err < 0.02, "max error {err}"); + assert_eq!(boxes_for_gauss(10.0, 3).len(), 3); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 471ebe56..5f7a66c9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -129,22 +129,35 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { /// Downscaled RGBA8 render of the document (nearest-neighbour sampling) for thumbnails or navigators. 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. let b = doc.bounds(); let scale = (max_side as f32 / b.width().max(b.height()).max(1) 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 (fw, fh) = (b.width() as usize, b.height() as usize); let mut img = Rgba8Image::new(w, h); - let full = if scale >= 1.0 { Some(flatten(doc)) } else { None }; - for ty in 0..h { - for tx in 0..w { - let x = ((tx as f32 + 0.5) / scale) as i32; - let y = ((ty as f32 + 0.5) / scale) as i32; - let p = match &full { - Some(f) => f.get(x.min(b.x1 - 1), y.min(b.y1 - 1)), - None => render(doc, Rect::from_xywh(x, y, 1, 1)).px[0], - }; - let o = ((ty * w + tx) * 4) as usize; - for (dst, v) in img.pixels[o..o + 4].iter_mut().zip(p) { + 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]; + } + acc[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; } } @@ -235,7 +248,7 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { LayerContent::Fill(f) => match &layer.fill_cache { // Photoshop's own rendering, valid while the fill is unchanged. Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect), - _ => render_fill(f, rect, cx.canvas), + _ => render_fill(f, rect, fill_frame(layer, cx.canvas)), }, LayerContent::Adjustment(_) => return None, _ => match layer.surface() { @@ -257,6 +270,33 @@ pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer { Buffer { rect, px } } +/// The frame a fill layer's gradient is laid out in ("Align with layer", Photoshop's default): +/// the layer's bounds, i.e. the area its masks reveal: a hide-all pixel mask's painted area, or +/// the vector mask's path; otherwise the canvas. +pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect { + let mut frame = canvas; + if let Some(m) = &layer.mask + && m.enabled + && m.surface.default_pixel().first().is_some_and(|v| *v <= 0.0) + { + let b = m.surface.content_bounds().intersect(&canvas); + if !b.is_empty() { + frame = b; + } + } + if let Some(vm) = &layer.vector_mask + && vm.enabled + && !vm.path.inverted + && let Some((x0, y0, x1, y1)) = vm.path.control_bounds() + { + let b = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32).intersect(&frame); + if !b.is_empty() { + frame = b; + } + } + frame +} + fn render_fill(f: &Fill, rect: Rect, canvas: Rect) -> Buffer { match f { Fill::Solid(c) => { @@ -264,7 +304,7 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect) -> Buffer { Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha]) } Fill::Gradient { stops, angle, scale, style, reverse } => { - // Gradient geometry relative to the canvas, independent of the render rect. + // Gradient geometry relative to the layer's frame, independent of the render rect. let mut b = Buffer::transparent(rect); for y in rect.y0..rect.y1 { for x in rect.x0..rect.x1 { @@ -418,6 +458,16 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: effects::composite_with_effects(layer, &content, backdrop, &cx.light, layer_bounds(layer, cx.canvas)); return; } + if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) { + for c in clipped.iter().filter(|c| c.visible) { + composite_atop(c, &mut content, cx); + } + for (p, s) in content.px.iter_mut().zip(&stroke.px) { + *p = psblend::composite(BlendMode::Normal, *p, *s, 1.0); + } + blend_into(backdrop, &content, layer.blend, opacity); + return; + } let Some(mut content) = render_content(layer, rect, cx) else { return }; for c in clipped.iter().filter(|c| c.visible) { composite_atop(c, &mut content, cx); @@ -425,6 +475,28 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: blend_into(backdrop, &content, layer.blend, opacity); } +/// A stroked shape layer with visible clipped layers: Photoshop draws the shape's vector stroke +/// above the clipped layers, so the base content is the fill alone and the stroke is laid on top +/// after clipping. Returns (fill, stroke) buffers over `rect`, masks applied. +fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer)> { + let LayerContent::Shape(sh) = &layer.content else { return None }; + if sh.stroke.is_none() || !clipped.iter().any(|c| c.visible) { + return None; + } + let fmt = photocraft_color::PixelFormat::RGBA8; + let fill_only = photocraft_doc::vector::ShapeLayer { stroke: None, cache: None, ..sh.clone() }; + let stroke_only = photocraft_doc::vector::ShapeLayer { fill: None, cache: None, ..sh.clone() }; + let mut f = surface_to_buffer(&photocraft_vector::render_shape(&fill_only, fmt, cx.canvas.intersect(&rect)), rect); + let mut s = surface_to_buffer(&photocraft_vector::render_shape(&stroke_only, fmt, cx.canvas.intersect(&rect)), rect); + if let Some(m) = mask_vals(layer, rect) { + for ((a, b), k) in f.px.iter_mut().zip(s.px.iter_mut()).zip(&m) { + a[3] *= k; + b[3] *= k; + } + } + Some((f, s)) +} + /// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics). fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let rect = base.rect; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 4a390610..eab8f0bc 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -193,6 +193,13 @@ impl<'a> Planner<'a> { self.check(layer)?; let opacity = layer.opacity * layer.fill_opacity; let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect(); + if let LayerContent::Shape(sh) = &layer.content + && sh.stroke.is_some() + && !visible_clipped.is_empty() + { + // The vector stroke goes above the clipped layers (CPU path splits fill and stroke). + return Err(Unsupported(format!("stroked shape `{}` with clipped layers", layer.name))); + } if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough @@ -261,7 +268,7 @@ impl<'a> Planner<'a> { p.mask = mask_use(layer); match &layer.fill_cache { Some(c) if c.fill == *f => self.surface_tex(&mut p, layer.id, &c.surface), - _ => self.fill(&mut p, f), + _ => self.fill(&mut p, f, photocraft_compose::fill_frame(layer, self.canvas)), } Ok(self.emit(p)) } @@ -284,7 +291,7 @@ impl<'a> Planner<'a> { } } - fn fill(&self, p: &mut Pass<'a>, f: &Fill) { + fn fill(&self, p: &mut Pass<'a>, f: &Fill, frame: photocraft_geom::Rect) { match f { Fill::Solid(c) => { let rgb = c.to_rgb(); @@ -300,7 +307,7 @@ impl<'a> Planner<'a> { photocraft_doc::GradientStyle::Diamond => 4.0, }; p.params[0] = [*angle, *scale, if *reverse { 1.0 } else { 0.0 }, style_i]; - let c = self.canvas; + let c = frame; p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32]; let conv: Vec<(f32, [f32; 4])> = stops .iter() diff --git a/crates/io/examples/dbg_corpus.rs b/crates/io/examples/dbg_corpus.rs deleted file mode 100644 index 3ab92c7c..00000000 --- a/crates/io/examples/dbg_corpus.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Temporary debugging aid: print our flatten vs Photoshop's merged image for a PSD. -fn main() { - let path = std::env::args().nth(1).expect("path"); - let bytes = std::fs::read(&path).unwrap(); - let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); - let imp = photocraft_io::import(&path, &bytes).unwrap(); - let doc = &imp.document; - println!("mode {:?} depth {:?} layers {} warnings {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings); - for l in doc.walk() { - let l = l.2; - println!(" layer {:?} {:?} blend {:?} op {} fill {} fill_cache {}", l.name, l.content.kind_name(), l.blend, l.opacity, l.fill_opacity, l.fill_cache.is_some()); - if let photocraft_doc::LayerContent::Fill(f) = &l.content { - println!(" {f:?}"); - } - for (k, v) in &l.psd_blocks { - if k == b"GdFl" { - // Skip the 4-byte version before the descriptor. - if let Ok((d, _)) = photocraft_psd::descriptor::Descriptor::parse_prefix(&v[4..]) { - println!(" GdFl keys: {:?}", d.items.iter().map(|(k, v)| format!("{k}={v:?}")).filter(|s| !s.starts_with("Grad")).collect::>()); - } - } - } - } - let ours = photocraft_compose::flatten(doc).px; - let merged = photocraft_io::merged_composite(&file).unwrap(); - let n = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(8usize); - for (i, (a, b)) in ours.iter().zip(&merged).enumerate().take(n) { - println!("{i:4}: ours {:?}\n ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); - } -} diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs new file mode 100644 index 00000000..85c5fb4a --- /dev/null +++ b/crates/io/examples/oracle_diff.rs @@ -0,0 +1,149 @@ +//! Compare our composite with Photoshop's merged image for one PSD (rendering-fidelity work). +//! +//! ```sh +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd [N] # layers + first N pixels +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 png out.png # ours | Photoshop | diff heatmap +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 col [x] # column samples +//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 row y x0 x1 # row samples +//! DUMP_FX=1 … # raw effects descriptors +//! ``` +fn main() { + let path = std::env::args().nth(1).expect("path"); + let bytes = std::fs::read(&path).unwrap(); + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let imp = photocraft_io::import(&path, &bytes).unwrap(); + let doc = &imp.document; + println!("mode {:?} depth {:?} layers {} warnings {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings); + for l in doc.walk() { + let l = l.2; + println!(" layer {:?} {:?} blend {:?} op {} fill {} fill_cache {}", l.name, l.content.kind_name(), l.blend, l.opacity, l.fill_opacity, l.fill_cache.is_some()); + if let photocraft_doc::LayerContent::Fill(f) = &l.content { + println!(" {f:?}"); + } + if let Some(m) = &l.mask { + println!(" mask default {:?} bounds {:?} enabled {}", m.surface.default_pixel(), m.surface.content_bounds(), m.enabled); + let b = m.surface.content_bounds(); + let cx = (b.x0 + b.x1) / 2; + let col: Vec = (b.y0..b.y0 + 6).chain(b.y1 - 6..b.y1).map(|y| format!("{y}:{:.2}", m.surface.pixel(cx, y)[0])).collect(); + println!(" mask column x={cx}: {}", col.join(" ")); + let row: Vec = (b.x0..b.x0 + 4).chain(b.x1 - 4..b.x1).map(|x| format!("{x}:{:.2}", m.surface.pixel(x, (b.y0 + b.y1) / 2)[0])).collect(); + println!(" mask row: {}", row.join(" ")); + } + if let Some(vm) = &l.vector_mask { + println!(" vector mask bounds {:?}", vm.path.control_bounds()); + } + println!(" clipped {} frame {:?} bounds {:?}", l.clipped, photocraft_compose::fill_frame(l, doc.bounds()), l.surface().map(|s| s.content_bounds())); + for e in &l.effects.items { + if let photocraft_doc::Effect::GradientOverlay { common, gradient, .. } = e + && common.enabled + { + let g = format!("{gradient:?}"); + println!(" gradient overlay: {g}"); + } else if format!("{e:?}").contains("enabled: true") { + println!(" fx {}", format!("{e:?}").chars().take(200).collect::()); + } + } + println!(" blocks {:?}", l.psd_blocks.iter().map(|(k, v)| format!("{}({})", String::from_utf8_lossy(k), v.len())).collect::>()); + for (k, v) in &l.psd_blocks { + if k == b"lfx2" || k == b"lmfx" { + // Recursively list descriptor keys (effects), skipping colour stop lists. + fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec) { + for (k, v) in &d.items { + let key = match k { photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => String::from_utf8_lossy(other.as_bytes()).to_string() }; + let vs = format!("{v:?}"); + match v { + photocraft_psd::descriptor::Value::Descriptor(sub) => { out.push(format!("{}{key}:", " ".repeat(depth))); walk(sub, depth + 1, out); } + _ => out.push(format!("{}{key} = {}", " ".repeat(depth), vs.chars().take(90).collect::())), + } + } + } + if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&v[4..]) { + let mut out = Vec::new(); + walk(&vd.descriptor, 3, &mut out); + for l in out.iter().filter(|l| !l.trim_start().starts_with("Clrs") && !l.trim_start().starts_with("Trns")) { println!("{l}"); } + } + } + if k == b"GdFl" { + // Skip the 4-byte version before the descriptor. + if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(v) { + println!(" GdFl keys: {:?}", vd.descriptor.items.iter().filter(|(k, _)| !format!("{k:?}").contains("71, 114, 97, 100")).map(|(k, v)| format!("{:?}={}", match k { photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(), other => format!("{other:?}") }, format!("{v:?}").chars().take(120).collect::())).collect::>()); + } + } + } + } + if std::env::var_os("DUMP_FX").is_some() { + fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize) { + for (k, v) in &d.items { + let key = String::from_utf8_lossy(k.as_bytes()).to_string(); + if key == "Clrs" || key == "Trns" { + continue; + } + match v { + photocraft_psd::descriptor::Value::Descriptor(sub) => { + println!("{}{key}:", " ".repeat(depth)); + walk(sub, depth + 1); + } + _ => println!("{}{key} = {}", " ".repeat(depth), format!("{v:?}").chars().take(600).collect::()), + } + } + } + for rec in file.layers() { + if let Some(b) = rec.block(b"lfx2").or(rec.block(b"lmfx")) { + if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..]) { + walk(&vd.descriptor, 1); + } + } + } + } + let ours = photocraft_compose::flatten(doc).px; + let merged = photocraft_io::merged_composite(&file).unwrap(); + if std::env::args().nth(3).as_deref() == Some("png") { + // ours | photoshop | diff heatmap, side by side (over white). + let out = std::env::args().nth(4).expect("out.png"); + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let mut img = vec![0u8; w * 3 * h * 4]; + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + for y in 0..h { + for x in 0..w { + let (a, b) = (ours[y * w + x], merged[y * w + x]); + let over = |p: [f32; 4]| [p[0] * p[3] + 1.0 - p[3], p[1] * p[3] + 1.0 - p[3], p[2] * p[3] + 1.0 - p[3]]; + let (oa, ob) = (over(a), over(b)); + let d = (0..3).map(|c| (oa[c] - ob[c]).abs()).fold(0.0f32, f32::max); + let heat = [q((d * 4.0).min(1.0)), q((1.0 - d * 4.0).max(0.0) * 0.3), 0]; + for (k, px) in [[q(oa[0]), q(oa[1]), q(oa[2])], [q(ob[0]), q(ob[1]), q(ob[2])], heat].iter().enumerate() { + let o = (y * w * 3 + k * w + x) * 4; + img[o..o + 3].copy_from_slice(px); + img[o + 3] = 255; + } + } + } + let image = photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img).unwrap(); + std::fs::write(&out, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + println!("wrote {out}"); + return; + } + if std::env::args().nth(3).as_deref() == Some("row") { + let w = doc.size.width as usize; + let y: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0); + let (x0, x1): (usize, usize) = (std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w)); + for x in (x0..x1).step_by(((x1 - x0) / 20).max(1)) { + let (a, b) = (ours[y * w + x], merged[y * w + x]); + println!("x={x:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } + return; + } + if std::env::args().nth(3).as_deref() == Some("col") { + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let x = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(w / 2); + for k in 0..=16 { + let y = ((h - 1) * k / 16).min(h - 1); + let (a, b) = (ours[y * w + x], merged[y * w + x]); + println!("y={y:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } + return; + } + let n = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(8usize); + for (i, (a, b)) in ours.iter().zip(&merged).enumerate().take(n) { + println!("{i:4}: ours {:?}\n ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0)); + } +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 714c7d5b..9df79dfd 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -148,8 +148,11 @@ pub(crate) fn gradient_style_value(s: GradientStyle) -> Value { /// Colour stops and opacity stops of a gradient. pub(crate) type Stops = (Vec<(f32, Color)>, Vec<(f32, f32)>); -pub(crate) fn gradient_stops(grad: &Descriptor) -> Stops { +/// Stops of a `Grdn` descriptor, baked to Photoshop's interpolation (smoothness `Intr`, stop +/// midpoints, and the parent's `gs99` interpolation method when given). +pub(crate) fn gradient_stops_with(grad: &Descriptor, method: Option<&[u8]>) -> Stops { let mut stops = Vec::new(); + let mut mids = Vec::new(); let mut opacity = Vec::new(); if let Some(Value::List(items)) = grad.get("Clrs") { for it in items { @@ -157,10 +160,17 @@ pub(crate) fn gradient_stops(grad: &Descriptor) -> Stops { let loc = num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0; if let Some(c) = get_desc(s, "Clr ").and_then(color_from_desc) { stops.push((loc, c)); + mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0); } } } } + // Midpoint k applies to the segment after stop k. + let mut order: Vec = (0..stops.len()).collect(); + order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0)); + let mids: Vec = order.iter().skip(1).map(|i| mids[*i]).collect(); + let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32); + let stops = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::from_code(method)) } else { stops }; if let Some(Value::List(items)) = grad.get("Trns") { for it in items { if let Value::Descriptor(s) = it { @@ -201,7 +211,8 @@ pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> D Descriptor::new("Grdn") .with("Nm ", Value::Text(UnicodeString::new_nul("Custom"))) .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("CstS") }) - .with("Intr", Value::Double(4096.0)) + // Our stops interpolate linearly (Photoshop smoothness is baked into them on import). + .with("Intr", Value::Double(0.0)) .with("Clrs", Value::List(clrs)) .with("Trns", Value::List(trns)) } @@ -221,7 +232,7 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { b"GdFl" => { let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); - let (mut stops, _) = get_desc(d, "Grad").map(gradient_stops).unwrap_or_default(); + let (mut stops, _) = get_desc(d, "Grad").map(|g| gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default(); if stops.is_empty() { stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; } diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 04df4dc9..464f142f 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -11,7 +11,7 @@ use photocraft_doc::{ }; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; -use crate::blocks::{bool_of, color_from_desc, color_to_desc, enum_of, get_desc, gradient_desc, gradient_stops, gradient_style, gradient_style_value, num}; +use crate::blocks::{bool_of, color_from_desc, color_to_desc, enum_of, get_desc, gradient_desc, gradient_style, gradient_style_value, num}; const BLEND_NAMES: [(BlendMode, &str); 28] = [ (BlendMode::Normal, "Nrml"), @@ -108,7 +108,7 @@ fn common(d: &Descriptor, blend: BlendMode, opacity: f32) -> FxCommon { } fn gradient_from(d: &Descriptor) -> Gradient { - let (stops, opacity_stops) = get_desc(d, "Grad").map(gradient_stops).unwrap_or_default(); + let (stops, opacity_stops) = get_desc(d, "Grad").map(|g| crate::blocks::gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default(); let off = get_desc(d, "Ofst"); Gradient { stops: if stops.is_empty() { Gradient::default().stops } else { stops }, diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs new file mode 100644 index 00000000..0761b184 --- /dev/null +++ b/crates/io/src/gradient_bake.rs @@ -0,0 +1,192 @@ +//! Photoshop gradient appearance → dense linear stops. +//! +//! Photoshop interpolates gradient colours with a *Smoothness* setting (0–100 %) and, since the +//! 2023 releases, an interpolation *method* (Perceptual / Linear / Classic). The compositors +//! interpolate stops linearly in sRGB, so on import we sample Photoshop's curve into dense stops. +//! The model was fitted to Photoshop's own composites in the test corpus (clean-room, from +//! pixels): Smoothness blends linear interpolation with a Catmull-Rom spline through the stop +//! colours (end tangents at half the chord slope); Perceptual interpolates in Oklab, Linear in +//! linear light, Classic in sRGB. Stop midpoints remap each segment piecewise-linearly. + +use photocraft_color::Color; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Method { + Classic, + Perceptual, + Linear, +} + +impl Method { + pub(crate) fn from_code(code: Option<&[u8]>) -> Method { + match code { + Some(b"Perc") => Method::Perceptual, + Some(b"Lnr ") => Method::Linear, + _ => Method::Classic, + } + } +} + +fn lin(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} +fn enc(c: f32) -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.003_130_8 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } +} + +fn to_oklab(rgb: [f32; 3]) -> [f32; 3] { + let [r, g, b] = rgb.map(lin); + let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt(); + let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt(); + let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt(); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +fn from_oklab(p: [f32; 3]) -> [f32; 3] { + let l = (p[0] + 0.396_337_78 * p[1] + 0.215_803_76 * p[2]).powi(3); + let m = (p[0] - 0.105_561_346 * p[1] - 0.063_854_17 * p[2]).powi(3); + let s = (p[0] - 0.089_484_18 * p[1] - 1.291_485_5 * p[2]).powi(3); + [ + 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s, + -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s, + -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s, + ] + .map(enc) +} + +fn into_space(m: Method, rgb: [f32; 3]) -> [f32; 3] { + match m { + Method::Classic => rgb, + Method::Linear => rgb.map(lin), + Method::Perceptual => to_oklab(rgb), + } +} + +fn out_of_space(m: Method, p: [f32; 3]) -> [f32; 3] { + match m { + Method::Classic => p.map(|v| v.clamp(0.0, 1.0)), + Method::Linear => p.map(enc), + Method::Perceptual => from_oklab(p), + } +} + +/// Colour at `t` for sorted stops (positions in 0..1) in `m`'s space. +fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32) -> [f32; 3] { + let n = stops.len(); + if t <= stops[0].0 { + return stops[0].1; + } + if t >= stops[n - 1].0 { + return stops[n - 1].1; + } + let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap_or(0); + let (t0, t1) = (stops[i].0, stops[i + 1].0); + let h = (t1 - t0).max(1e-6); + let mut u = (t - t0) / h; + // Midpoint: where the segment reaches 50 % (default 0.5 = identity). + let mid = mids.get(i).copied().unwrap_or(0.5).clamp(0.05, 0.95); + u = if u <= mid { 0.5 * u / mid } else { 0.5 + 0.5 * (u - mid) / (1.0 - mid) }; + let (p0, p1) = (stops[i].1, stops[i + 1].1); + // Catmull-Rom tangents per unit t; the ends use half the chord slope. + let tangent = |k: usize| -> [f32; 3] { + let (a, b) = match k { + 0 => (0, 1), + k if k == n - 1 => (n - 2, n - 1), + k => (k - 1, k + 1), + }; + let d = (stops[b].0 - stops[a].0).max(1e-6); + let half = if k == 0 || k == n - 1 { 0.5 } else { 1.0 }; + std::array::from_fn(|c| (stops[b].1[c] - stops[a].1[c]) / d * half) + }; + let (m0, m1) = (tangent(i), tangent(i + 1)); + let (u2, u3) = (u * u, u * u * u); + let (h00, h10, h01, h11) = (2.0 * u3 - 3.0 * u2 + 1.0, u3 - 2.0 * u2 + u, -2.0 * u3 + 3.0 * u2, u3 - u2); + std::array::from_fn(|c| { + let linear = p0[c] + (p1[c] - p0[c]) * u; + let spline = h00 * p0[c] + h10 * h * m0[c] + h01 * p1[c] + h11 * h * m1[c]; + linear + (spline - linear) * smooth + }) +} + +/// Dense linear stops reproducing Photoshop's interpolation. Returns the input unchanged when it +/// already interpolates linearly in sRGB. +pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { + let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); + if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) { + return stops; + } + let mut s = stops; + s.sort_by(|a, b| a.0.total_cmp(&b.0)); + let alpha = |t: f32| -> f32 { + // Stop colours carry no alpha in Photoshop (opacity has its own stops); keep the nearest. + s.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map_or(1.0, |x| x.1.alpha) + }; + let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, into_space(method, c.to_rgb()))).collect(); + const N: usize = 96; + let mut out: Vec<(f32, Color)> = (0..=N) + .map(|k| { + let t = k as f32 / N as f32; + let rgb = out_of_space(method, eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t)); + (t, Color::rgba(rgb[0], rgb[1], rgb[2], alpha(t))) + }) + .collect(); + out.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(stops: &[(f32, Color)], t: f32) -> [f32; 3] { + let i = stops.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let (a, b) = (&stops[i], &stops[i + 1]); + let u = (t - a.0) / (b.0 - a.0); + let (x, y) = (a.1.to_rgb(), b.1.to_rgb()); + std::array::from_fn(|c| x[c] + (y[c] - x[c]) * u) + } + + #[test] + fn classic_without_smoothness_is_untouched() { + let s = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + assert_eq!(bake(s.clone(), &[0.5], 0.0, Method::Classic), s); + } + + #[test] + fn smoothness_eases_between_two_stops() { + // Two stops, 100 % smoothness: t + ½(smoothstep(t) − t) (Catmull-Rom with half-slope ends). + let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.5], 1.0, Method::Classic); + for t in [0.1f32, 0.25, 0.5, 0.75, 0.9] { + let ss = t * t * (3.0 - 2.0 * t); + let want = t + 0.5 * (ss - t); + assert!((at(&s, t)[0] - want).abs() < 2e-3, "{t}: {} vs {want}", at(&s, t)[0]); + } + } + + #[test] + fn perceptual_matches_photoshop_sample() { + // Measured from Photoshop's composite (ag-psd gradient-mode): orange→blue, Perceptual, + // 100 % smoothness; at t≈0.491 Photoshop shows (0.592, 0.612, 0.596). + let s = bake(vec![(0.0, Color::rgb(0.882, 0.602, 0.201)), (1.0, Color::rgb(0.164, 0.568, 0.851))], &[0.5], 1.0, Method::Perceptual); + let p = at(&s, 0.491); + for (got, want) in p.iter().zip([0.592f32, 0.612, 0.596]) { + assert!((got - want).abs() < 0.01, "{p:?}"); + } + // And near the orange end (t≈0.113 → Photoshop 0.839, 0.608, 0.290). + let p = at(&s, 0.113); + for (got, want) in p.iter().zip([0.839f32, 0.608, 0.290]) { + assert!((got - want).abs() < 0.012, "{p:?}"); + } + } + + #[test] + fn midpoint_shifts_the_half_way_colour() { + let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic); + assert!((at(&s, 0.25)[0] - 0.5).abs() < 0.01); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 13227232..94006ec4 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -20,6 +20,7 @@ mod adjust_map; pub mod effects_map; pub mod blocks; +mod gradient_bake; mod flat; mod pixels; mod psd_export; diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 193d6f3c..7a7e767b 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -55,7 +55,10 @@ fn main() { }); harness.run_steps(4); let ctx = harness.ctx.clone(); + let timing = std::env::var_os("SNAPSHOT_TIMING").is_some(); for (method, params) in script { + let t0 = std::time::Instant::now(); + let label = format!("{method} {}", params.get("command").and_then(Value::as_str).unwrap_or("")); let (req, _rx) = ControlRequest::new(&method, params); let outcome = handle(harness.state_mut(), &ctx, &req); if let Outcome::Done(v) = &outcome @@ -75,6 +78,9 @@ fn main() { harness.step(); } harness.run_steps(4); + if timing { + eprintln!("{:>8.1} ms {label}", t0.elapsed().as_secs_f64() * 1000.0); + } } // Let fade animations settle. for _ in 0..12 { diff --git a/docs/development.md b/docs/development.md index 70939174..518615ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -179,3 +179,19 @@ cargo run --release -p photocraft-ui-egui --example snapshot -- \ `--script` is a list of control-protocol calls (`[method, params]`), applied in order. Input calls (`ui.key`, `ui.type`, `ui.click`) now reply only after the app has processed the events, so a following `ui.inspect` observes their effect. + + +## Rendering fidelity (PSD oracle) + +`cargo test --release -p photocraft-io --test corpus -- --nocapture` compares our composite of every +corpus PSD with Photoshop's own merged image (PASS ≤ 2/255). To dig into one file: + +```sh +cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/.psd 0 png /tmp/diff.png +``` + +writes ours | Photoshop | a diff heatmap side by side; `col`, `row` and `DUMP_FX=1` print samples and raw +effect descriptors. Findings so far: fill-layer gradients are framed by the layer's mask bounds; +Photoshop's gradient Smoothness is a Catmull-Rom blend and "Perceptual" interpolation is Oklab +(baked into dense stops on import, `crates/io/src/gradient_bake.rs`); a shape layer's vector +stroke is drawn above its clipped layers. diff --git a/docs/images/SOURCES.md b/docs/images/SOURCES.md new file mode 100644 index 00000000..6505d047 --- /dev/null +++ b/docs/images/SOURCES.md @@ -0,0 +1,7 @@ +# Image sources + +| File | Content | Source / licence | +|---|---|---| +| `photocraft-demo.jpg` | Photocraft UI screenshot (rendered offscreen with `cargo run -p photocraft-ui-egui --example snapshot`) editing *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg). UI: this project. | + +Screenshots must use famous public-domain artwork (or content made in the app), never personal photos. diff --git a/docs/images/photocraft-demo.jpg b/docs/images/photocraft-demo.jpg new file mode 100644 index 00000000..b4ac9dc0 Binary files /dev/null and b/docs/images/photocraft-demo.jpg differ