readme, more impl
This commit is contained in:
parent
ae92ac20bf
commit
a517477da8
37
README.md
37
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.
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
mod adjust_map;
|
||||
pub mod effects_map;
|
||||
pub mod blocks;
|
||||
mod gradient_bake;
|
||||
mod flat;
|
||||
mod pixels;
|
||||
mod psd_export;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue