readme, more impl

This commit is contained in:
Brandon Thomas 2026-09-30 11:15:03 -04:00
parent ae92ac20bf
commit a517477da8
14 changed files with 650 additions and 54 deletions

View File

@ -5,9 +5,15 @@ By the artcraft team
An open-source, native image editor written in Rust, aiming for Photoshop-level capability and ergonomics.
<p align="center">
<img src="docs/images/photocraft-demo.jpg" alt="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" width="100%">
<br>
<sub>Artwork: <i>The Great Wave off Kanagawa</i>, Katsushika Hokusai, c. 1831 (public domain, via Wikimedia Commons).</sub>
</p>
- **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.
<table>
<tr>
<td width="20%" valign="top">
<h3><a href="https://github.com/storytold/photocraft">📷 PhotoCraft</a></h3>
Layered image editing and compositing: the Photoshop workflow.
</td>
<td width="20%" valign="top">
<h3><a href="https://github.com/storytold/drawcraft">✏️ DrawCraft</a></h3>
Vector illustration: the Illustrator workflow.
</td>
<td width="20%" valign="top">
<h3><a href="https://github.com/storytold/filmcraft">🎬 FilmCraft</a></h3>
Non-linear video editing: the Premiere Pro workflow.
</td>
<td width="20%" valign="top">
<h3><a href="https://github.com/storytold/lightcraft">🌄 LightCraft</a></h3>
Photo library and non-destructive raw developer: the Lightroom workflow.
</td>
<td width="20%" valign="top">
<h3><a href="https://github.com/storytold/printcraft">📄 PrintCraft</a></h3>
PDF viewing and editing: the Acrobat Pro workflow.
</td>
</tr>
</table>

View File

@ -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<f32> {
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<usize> {
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<f32> {
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<f32> {
let n = src.ch;
let boxes = boxes_for_gauss(sigma, 3);
let margin: i32 = boxes.iter().map(|w| (*w as i32 - 1) / 2).sum::<i32>() + 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<f32> {
@ -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);
}
}

View File

@ -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<Buffer> {
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;

View File

@ -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()

View File

@ -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::<Vec<_>>());
}
}
}
}
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));
}
}

View File

@ -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<String> = (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<String> = (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::<String>());
}
}
println!(" blocks {:?}", l.psd_blocks.iter().map(|(k, v)| format!("{}({})", String::from_utf8_lossy(k), v.len())).collect::<Vec<_>>());
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<String>) {
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::<String>())),
}
}
}
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::<String>())).collect::<Vec<_>>());
}
}
}
}
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::<String>()),
}
}
}
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));
}
}

View File

@ -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<usize> = (0..stops.len()).collect();
order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0));
let mids: Vec<f32> = 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<Fill> {
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)];
}

View File

@ -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 },

View File

@ -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);
}
}

View File

@ -20,6 +20,7 @@
mod adjust_map;
pub mod effects_map;
pub mod blocks;
mod gradient_bake;
mod flat;
mod pixels;
mod psd_export;

View File

@ -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 {

View File

@ -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/<file>.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.

7
docs/images/SOURCES.md Normal file
View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB