Merge origin/main into feat/tool-213-7-live-retouch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David MannandClaude Opus 5.5 committed 2026-10-13 20:10:40 +02:00
commit 577c4eea67
179 files changed
+11219 -887

No files matched your search

+5 -5
View File
@@ -298,13 +298,14 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
@@ -350,9 +351,8 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
+1
View File
@@ -337,6 +337,7 @@ mod tests {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
import(&mut s);
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert!(b.unsaved());
+6 -1
View File
@@ -305,7 +305,12 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if let Some(error) = self.presets.error() {
// Only while a preset change is unsaved. Storage the browser denies from the start (a
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
// change brings the banner with the reason.
if self.presets.unsaved()
&& let Some(error) = self.presets.error()
{
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
+27
View File
@@ -177,6 +177,12 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
plan(backend, crashed, env, safe_gpu, os)
}
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
@@ -541,6 +547,27 @@ mod tests {
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
/// #3483: A temporary CPU recovery must not change the next launch.
#[test]
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
let gl_crash = crashed("gl", "gl");
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
assert_eq!(backend_to_remember(&cpu), None);
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
assert_eq!(safe.backend, Cpu);
assert_eq!(backend_to_remember(&safe), None);
// An unconfirmed fallback leaves the original automatic selection intact.
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
// A working hardware fallback can still be remembered.
let vk_crash = crashed("auto", "vulkan");
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
assert_eq!(backend_to_remember(&gl), Some(Gl));
// CPU explicitly selected in preferences still works.
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
+8 -15
View File
@@ -75,7 +75,8 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
@@ -330,6 +331,9 @@ fn main() -> eframe::Result {
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Centre only the first window: eframe centres after restoring the saved position, so a
// window closed on another monitor reopened in the middle of the main one (#4826).
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
@@ -449,24 +453,13 @@ fn main() -> eframe::Result {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
// CPU rendering after a failed GPU start is temporary. Only the
// recovery-dialog choice may persist it (#3483).
let remember = gpu_startup::backend_to_remember(&plan);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
+33 -1
View File
@@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) {
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
@@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::check;
use super::{check, saved_position};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -105,6 +122,21 @@ mod tests {
}
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(!saved_position(&file(no_position, "1.0")));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
}
assert!(!super::has_saved_position(None));
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
+990
View File
@@ -0,0 +1,990 @@
//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map
//! from the aligned stack, the index of the layer each pixel is sharpest in.
//!
//! The pipeline is the non-learned "focus volume" recipe, written from the papers:
//!
//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter*
//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From
//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small
//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise
//! away while staying local. Block-averaged to a working grid (half resolution).
//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter*
//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware
//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013).
//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its
//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*,
//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence,
//! the profile mean for a prominence term and the profile minimum for a noise gate.
//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware
//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008)
//! whose data term is the confidence, solved by conjugate gradients preconditioned by a
//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu,
//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated
//! outliers stop pulling.
//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients.
//!
//! Everything is deterministic; the slices are folded one at a time, so memory is one working
//! grid per layer plus a few planes.
use crate::photo_util::{par_map, par_rows, par_rows2};
/// Depth-from-focus settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DepthParams {
/// The working grid is `1 / 2^scale` of full resolution (1 = half).
pub scale: usize,
/// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`.
pub r_in: usize,
pub r_out: usize,
/// Guided-filter aggregation radius (working-grid pixels) and regulariser.
pub agg_radius: usize,
pub agg_eps: f32,
/// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation.
pub lambda: f32,
pub sigma_c: f32,
/// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median
/// over pixels of the profile minimum). 0 = off.
pub gate: f32,
/// Robust data term: after the first solve the data weights are scaled by the Huber factor
/// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off.
pub robust: f32,
}
impl Default for DepthParams {
fn default() -> Self {
DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 }
}
}
/// The result of [`depth_from_focus`].
#[derive(Clone, Debug, PartialEq)]
pub struct DepthMap {
/// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`.
pub depth: Vec<f32>,
/// Full-resolution confidence in `0 ..= 1`.
pub conf: Vec<f32>,
pub w: usize,
pub h: usize,
}
/// Mean over `k × k` blocks (partial blocks at the far edges use their own count).
pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec<f32>, usize, usize) {
let k = k.max(1);
let (dw, dh) = (w.div_ceil(k), h.div_ceil(k));
if k == 1 {
return (src.to_vec(), dw, dh);
}
let mut out = vec![0.0f32; dw * dh];
par_rows(&mut out, dw, 1, |oy, row| {
let (y0, y1) = (oy * k, (oy * k + k).min(h));
for y in y0..y1 {
let s = &src[y * w..y * w + w];
for (ox, o) in row.iter_mut().enumerate() {
*o += s[ox * k..(ox * k + k).min(w)].iter().sum::<f32>();
}
}
for (ox, o) in row.iter_mut().enumerate() {
*o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32;
}
});
(out, dw, dh)
}
/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to
/// `w × h`.
pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
if dw == 0 || dh == 0 {
return out;
}
let inv = 1.0 / k.max(1) as f32;
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32);
let y0 = fy as usize;
let y1 = (y0 + 1).min(dh - 1);
let ty = fy - y0 as f32;
let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32);
let x0 = fx as usize;
let x1 = (x0 + 1).min(dw - 1);
let tx = fx - x0 as f32;
let top = r0[x0] + (r0[x1] - r0[x0]) * tx;
let bot = r1[x0] + (r1[x1] - r1[x0]) * tx;
*o = top + (bot - top) * ty;
}
});
out
}
/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use
/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum
/// carried down the rows as one accumulator row.
struct BoxFilter {
w: usize,
h: usize,
r: usize,
inv_count: Vec<f32>,
}
impl BoxFilter {
fn new(w: usize, h: usize, r: usize) -> BoxFilter {
let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32;
let mut inv_count = vec![0.0f32; w * h];
par_rows(&mut inv_count, w, 1, |y, row| {
let cy = cnt(h, y);
for (x, o) in row.iter_mut().enumerate() {
*o = 1.0 / (cy * cnt(w, x));
}
});
BoxFilter { w, h, r, inv_count }
}
fn mean(&self, src: &[f32]) -> Vec<f32> {
let (w, h, r) = (self.w, self.h, self.r);
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let s = &src[y * w..y * w + w];
let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum();
for x in 0..w {
row[x] = acc as f32;
if x + r + 1 < w {
acc += f64::from(s[x + r + 1]);
}
if x >= r {
acc -= f64::from(s[x - r]);
}
}
});
let mut out = vec![0.0f32; w * h];
let mut acc = vec![0.0f64; w];
for y in 0..(r + 1).min(h) {
for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) {
*a += f64::from(*v);
}
}
for y in 0..h {
for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) {
*o = *a as f32 * c;
}
if y + r + 1 < h {
for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) {
*a += f64::from(*v);
}
}
if y >= r {
for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) {
*a -= f64::from(*v);
}
}
}
out
}
}
/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and
/// radius, it keeps the guide's statistics so a slice costs only the arithmetic.
struct GuidedFilter {
guide: Vec<f32>,
mean_i: Vec<f32>,
var_i: Vec<f32>,
bf: BoxFilter,
eps: f32,
}
impl GuidedFilter {
fn new(guide: Vec<f32>, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter {
let bf = BoxFilter::new(w, h, r);
let mean_i = bf.mean(&guide);
let ii: Vec<f32> = guide.iter().map(|v| v * v).collect();
let var_i: Vec<f32> = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect();
GuidedFilter { guide, mean_i, var_i, bf, eps }
}
/// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`.
fn coeffs(&self, p: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mean_p = self.bf.mean(p);
let ip: Vec<f32> = self.guide.iter().zip(p).map(|(i, p)| i * p).collect();
let corr_ip = self.bf.mean(&ip);
let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]);
for k in 0..p.len() {
let cov = corr_ip[k] - self.mean_i[k] * mean_p[k];
a[k] = cov / (self.var_i[k] + self.eps);
b[k] = mean_p[k] - a[k] * self.mean_i[k];
}
(self.bf.mean(&a), self.bf.mean(&b))
}
fn filter(&self, p: &[f32]) -> Vec<f32> {
let (a, b) = self.coeffs(p);
(0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect()
}
}
/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for
/// `r_in < r ≤ r_out`.
fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> {
let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64);
let (mut disk, mut ring) = (Vec::new(), Vec::new());
let r = r_out as i64;
for dy in -r..=r {
for dx in -r..=r {
let d2 = dx * dx + dy * dy;
if d2 <= ri2 {
disk.push((dy, dx));
} else if d2 <= ro2 {
ring.push((dy, dx));
}
}
}
let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32);
disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect()
}
/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter,
/// clamped borders.
pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec<f32> {
let taps = rdf_taps(r_in, r_out.max(r_in + 1));
let mut out = vec![0.0f32; w * h];
if w == 0 || h == 0 {
return out;
}
par_rows(&mut out, w, 1, |yy, row| {
for &(dy, dx, wt) in &taps {
let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize;
let s = &y[sy * w..sy * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize];
}
}
row.iter_mut().for_each(|v| *v = v.abs());
});
out
}
/// Per-pixel state of the focus profile, updated one slice at a time.
#[derive(Clone, Copy)]
struct Px {
/// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index.
c1: f32,
l1: f32,
r1: f32,
i1: u32,
/// Second-best local maximum (−1 = none).
c2: f32,
/// The last two slice values.
prev: f32,
prev2: f32,
sum: f32,
cmin: f32,
}
impl Px {
fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) {
if val > self.c1 {
if self.c1 >= 0.0 {
self.c2 = self.c1;
}
(self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r);
} else if val > self.c2 {
self.c2 = val;
}
}
}
/// The streaming peak search over the layer axis.
struct PeakTracker {
px: Vec<Px>,
m: usize,
}
impl PeakTracker {
fn new(n: usize) -> PeakTracker {
PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 }
}
/// Fold the next slice (aggregated focus values ≥ 0).
fn push(&mut self, c: &[f32]) {
let m = self.m;
for (p, &v) in self.px.iter_mut().zip(c) {
if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v {
let l = if m >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (m - 1) as u32, l, v);
}
p.sum += v;
p.cmin = p.cmin.min(v);
(p.prev2, p.prev) = (p.prev, v);
}
self.m += 1;
}
/// Close the profiles: the sub-layer depth and the raw confidence per pixel.
fn finish(mut self, gate: f32) -> (Vec<f32>, Vec<f32>) {
let n = self.m;
if n == 0 {
return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]);
}
for p in self.px.iter_mut() {
if n == 1 || p.prev >= p.prev2 {
let l = if n >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (n - 1) as u32, l, -1.0);
}
}
// The noise floor: the median of the per-pixel profile minimum (subsampled).
let mut mins: Vec<f32> = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect();
let floor = if mins.is_empty() {
0.0
} else {
let mid = mins.len() / 2;
*mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
};
let inv_n = 1.0 / n as f32;
let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| {
let p = &self.px[i];
let (c1, l, r) = (p.c1, p.l1, p.r1);
let mut delta = 0.0f32;
if l >= 0.0 && r >= 0.0 && c1 > 0.0 {
// Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain.
let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln());
let den = ll - 2.0 * lc + lr;
if den < 0.0 {
delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5);
}
}
let conf = if c1 > 0.0 {
let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0);
// A rival local maximum of similar height means an ambiguous profile.
let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 };
let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 };
prom * pkr * g
} else {
0.0
};
(p.i1 as f32 + delta, conf)
});
out.into_iter().unzip()
}
}
/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every
/// stack.
fn normalize_conf(conf: &mut [f32]) {
let mut sample: Vec<f32> = conf.iter().step_by(7).copied().collect();
if sample.is_empty() {
return;
}
let k = (sample.len() * 9 / 10).min(sample.len() - 1);
let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
if p90 > 1e-6 {
conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0));
}
}
/// 3×3 median, clamped borders.
fn median3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, w, 1, |y, row| {
let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)];
let mut v = [0.0f32; 9];
for (x, o) in row.iter_mut().enumerate() {
let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)];
for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() {
v[k] = src[yy * w + xx];
}
v.sort_unstable_by(|a, b| a.total_cmp(b));
*o = v[4];
}
});
out
}
/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits
/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`.
fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec<f32>, Vec<f32>) {
let inv = -1.0 / sigma.max(1e-6);
let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
par_rows2(&mut ax, &mut ay, w, |y, rx, ry| {
let g = &guide[y * w..y * w + w];
for x in 0..w.saturating_sub(1) {
rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp();
}
if y + 1 < h {
let g1 = &guide[(y + 1) * w..(y + 2) * w];
for x in 0..w {
ry[x] = ((g[x] - g1[x]).abs() * inv).exp();
}
}
});
(ax, ay)
}
/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} −
/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and
/// `i + 1`; `scratch` holds the sweep's two coefficient lines.
fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) {
let n = u.len();
if n == 0 || scratch.len() < 2 * n {
return;
}
let (cp, dp) = scratch.split_at_mut(n);
let mut prev_a = 0.0f32;
for i in 0..n {
let ai = if i + 1 < n { a[i] } else { 0.0 };
let diag = wd[i] + lam * (prev_a + ai);
let lower = -lam * prev_a;
let upper = -lam * ai;
let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) };
let m = diag - lower * c0;
let m = if m.abs() < 1e-12 { 1e-12 } else { m };
cp[i] = upper / m;
dp[i] = (wd[i] * f[i] - lower * d0) / m;
prev_a = ai;
}
u[n - 1] = dp[n - 1];
for i in (0..n - 1).rev() {
u[i] = dp[i] - cp[i] * u[i + 1];
}
}
/// The row sweeps of the fast global smoother, in parallel over rows.
fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) {
par_rows(u, w, 1, |y, row| {
let o = y * w;
let mut scratch = vec![0.0f32; 2 * w];
solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch);
});
}
/// The column sweeps: the planes are transposed, swept as rows, and transposed back.
fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) {
let t = |p: &[f32]| -> Vec<f32> {
let mut o = vec![0.0f32; w * h];
par_rows(&mut o, h, 1, |x, col| {
for (y, v) in col.iter_mut().enumerate() {
*v = p[y * w + x];
}
});
o
};
let (ft, wdt, ayt) = (t(f), t(wd), t(ay));
let mut ut = vec![0.0f32; w * h];
solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam);
par_rows(u, w, 1, |y, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = ut[x * h + y];
}
});
}
/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al.
/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the
/// later ones the previous output: the initial guess of [`wls_solve`].
fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec<f32> {
const T: i32 = 3;
let ones = vec![1.0f32; w * h];
let mut u = vec![0.0f32; w * h];
let mut f = d.to_vec();
for t in 1..=T {
let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0);
let wt: &[f32] = if t == 1 { wd } else { &ones };
solve_rows(&mut u, &f, wt, ex, w, lam_t);
f.copy_from_slice(&u);
solve_cols(&mut u, &f, &ones, ey, w, h, lam_t);
f.copy_from_slice(&u);
}
u
}
/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes
/// a V-cycle needs there.
struct MgLevel {
w: usize,
h: usize,
/// Data weight per cell; on the coarse grids the sum over the block.
wd: Vec<f32>,
/// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the
/// coarse grids the sum of the fine edges the block boundary cuts.
ax: Vec<f32>,
ay: Vec<f32>,
/// `1 / diag(A)`.
dinv: Vec<f32>,
x: Vec<f32>,
b: Vec<f32>,
r: Vec<f32>,
}
/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil).
const MG_OMEGA: f32 = 0.8;
/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where
/// they are the solve).
const MG_PRE: usize = 1;
const MG_POST: usize = 1;
const MG_COARSE: usize = 32;
/// Coarsen until the grid is this small on its longer side.
const MG_MIN: usize = 16;
impl MgLevel {
fn new(w: usize, h: usize) -> MgLevel {
let n = w * h;
MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] }
}
/// `out_i = f(i, (A x)_i)` over the grid.
fn apply<F: Fn(usize, f32) -> f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(out, w, 1, |y, row| {
let o = y * w;
for (i, r) in row.iter_mut().enumerate() {
let k = o + i;
let xk = x[k];
let mut v = wd[k] * xk;
if i > 0 {
v += ax[k - 1] * (xk - x[k - 1]);
}
if i + 1 < w {
v += ax[k] * (xk - x[k + 1]);
}
if y > 0 {
v += ay[k - w] * (xk - x[k - w]);
}
if y + 1 < h {
v += ay[k] * (xk - x[k + w]);
}
*r = f(k, v);
}
});
}
fn set_dinv(&mut self) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(&mut self.dinv, w, 1, |y, row| {
for (i, r) in row.iter_mut().enumerate() {
let k = y * w + i;
let mut v = wd[k];
if i > 0 {
v += ax[k - 1];
}
if i + 1 < w {
v += ax[k];
}
if y > 0 {
v += ay[k - w];
}
if y + 1 < h {
v += ay[k];
}
*r = 1.0 / v.max(1e-12);
}
});
}
/// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`.
fn jacobi(&mut self, zero_start: bool) {
if zero_start {
for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) {
*x = MG_OMEGA * d * b;
}
return;
}
let mut r = std::mem::take(&mut self.r);
{
let (x, b, dinv) = (&self.x, &self.b, &self.dinv);
self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax));
}
self.r = std::mem::replace(&mut self.x, r);
}
/// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant
/// prolongation (data weights summed over the block, the fine edges cut by a block boundary
/// summed into the coarse edge).
fn coarsen(&self) -> MgLevel {
let (w, h) = (self.w, self.h);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut c = MgLevel::new(cw, ch);
self.coarsen_wd_into(&mut c);
let (ax, ay) = (&self.ax, &self.ay);
par_rows(&mut c.ax, cw, 1, |yy, row| {
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
if xx + 1 < cw {
*o += ax[y * w + 2 * xx + 1];
}
}
}
});
par_rows(&mut c.ay, cw, 1, |yy, row| {
if yy + 1 < ch {
let y = 2 * yy + 1;
for (xx, o) in row.iter_mut().enumerate() {
*o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
c
}
/// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges
/// do not change).
fn coarsen_wd_into(&self, c: &mut MgLevel) {
let (w, h, cw) = (self.w, self.h, c.w);
let wd = &self.wd;
par_rows(&mut c.wd, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
*o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
}
/// `x += P x_c`: the coarse correction injected (piecewise constant).
fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) {
par_rows(x, w, 1, |y, row| {
let c = &xc[(y / 2) * cw..(y / 2) * cw + cw];
for (i, o) in row.iter_mut().enumerate() {
*o += c[i / 2];
}
});
}
/// `b_c = Pᵀ r`: the residual summed over each block.
fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) {
par_rows(bc, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
let s = &r[y * w..y * w + w];
for (xx, o) in row.iter_mut().enumerate() {
*o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`.
fn vcycle(levels: &mut [MgLevel]) {
let Some((top, rest)) = levels.split_first_mut() else { return };
let Some(next) = rest.first_mut() else {
for k in 0..MG_COARSE {
top.jacobi(k == 0);
}
return;
};
for k in 0..MG_PRE {
top.jacobi(k == 0);
}
{
let mut r = std::mem::take(&mut top.r);
let (x, b) = (&top.x, &top.b);
top.apply(x, &mut r, |k, ax| b[k] - ax);
top.r = r;
}
restrict(&top.r, top.w, top.h, &mut next.b, next.w);
vcycle(rest);
let Some(next) = rest.first() else { return };
prolong_add(&mut top.x, top.w, &next.x, next.w);
for _ in 0..MG_POST {
top.jacobi(false);
}
}
/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system
/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess,
/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes
/// converge where the confidence is low over a large area (a flat wall). The solver keeps its
/// hierarchy between solves, so a reweighting costs no rebuild.
struct WlsSolver {
w: usize,
h: usize,
lambda: f32,
/// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps.
ex: Vec<f32>,
ey: Vec<f32>,
levels: Vec<MgLevel>,
}
impl WlsSolver {
fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver {
let (ex, ey) = edge_weights(guide, w, h, sigma_c);
let mut top = MgLevel::new(w, h);
for (a, e) in top.ax.iter_mut().zip(&ex) {
*a = lambda * e;
}
for (a, e) in top.ay.iter_mut().zip(&ey) {
*a = lambda * e;
}
let mut levels = vec![top];
while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) {
let Some(last) = levels.last() else { break };
let c = last.coarsen();
levels.push(c);
}
WlsSolver { w, h, lambda, ex, ey, levels }
}
/// The data weights: the confidence (plus a floor so the system is definite where nothing
/// is known), aggregated down the hierarchy.
fn set_weights(&mut self, conf: &[f32]) {
const EPS_DATA: f32 = 1e-4;
if let Some(top) = self.levels.first_mut() {
for (w, c) in top.wd.iter_mut().zip(conf) {
*w = c.max(0.0) + EPS_DATA;
}
}
for l in 0..self.levels.len() {
if l > 0 {
let (fine, coarse) = self.levels.split_at_mut(l);
if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) {
f.coarsen_wd_into(c);
}
}
self.levels[l].set_dinv();
}
}
/// Solve for the data `d`: the solution and its final relative residual.
fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec<f32>, f32) {
let (w, h, lambda) = (self.w, self.h, self.lambda);
let n = w * h;
let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) };
let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda);
if w == 1 || h == 1 || n == 0 {
return (u, 0.0);
}
let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() };
let b: Vec<f32> = top.wd.iter().zip(d).map(|(w, d)| w * d).collect();
let bnorm = dot(&b, &b).sqrt().max(1e-30);
let mut r = vec![0.0f32; n];
top.apply(&u, &mut r, |k, au| b[k] - au);
let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| {
if let Some(top) = levels.first_mut() {
top.b.copy_from_slice(r);
}
vcycle(levels);
if let Some(top) = levels.first() {
z.copy_from_slice(&top.x);
}
};
let mut z = vec![0.0f32; n];
precond(&mut self.levels, &r, &mut z);
let mut p = z.clone();
let mut ap = vec![0.0f32; n];
let mut rz = dot(&r, &z);
let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32;
for _ in 0..max_iters {
if rel < 1e-5 {
break;
}
let Some(top) = self.levels.first() else { break };
top.apply(&p, &mut ap, |_, v| v);
let pap = dot(&p, &ap);
if pap <= 0.0 {
break;
}
let alpha = (rz / pap) as f32;
for (u, p) in u.iter_mut().zip(&p) {
*u += alpha * p;
}
for (r, ap) in r.iter_mut().zip(&ap) {
*r -= alpha * ap;
}
rel = (dot(&r, &r).sqrt() / bnorm) as f32;
if rel < 1e-5 {
break;
}
precond(&mut self.levels, &r, &mut z);
let rz_new = dot(&r, &z);
let beta = (rz_new / rz) as f32;
rz = rz_new;
for (p, z) in p.iter_mut().zip(&z) {
*p = z + beta * *p;
}
}
(u, rel)
}
}
/// Conjugate-gradient iteration cap of the regularisation.
const CG_ITERS: usize = 200;
/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by
/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a
/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere.
pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap {
let n = luma.len();
let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h);
if empty {
return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
}
let k = 1usize << p.scale.min(8);
let (g, dw, dh) = block_mean(guide, w, h, k);
let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps);
let mut tracker = PeakTracker::new(dw * dh);
for l in luma {
let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0;
if p.agg_radius > 0 {
// The guided filter can undershoot; the profile statistics assume ≥ 0.
let agg: Vec<f32> = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect();
tracker.push(&agg);
} else {
tracker.push(&slice);
}
}
let (depth_w, mut conf) = tracker.finish(p.gate);
let depth_w = median3(&depth_w, dw, dh);
normalize_conf(&mut conf);
let depth_w = if p.lambda > 0.0 {
let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c);
solver.set_weights(&conf);
let (u, _) = solver.solve(&depth_w, CG_ITERS);
if p.robust > 0.0 {
// One reweighted step with a Huber loss on the data residual.
let wd2: Vec<f32> = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect();
solver.set_weights(&wd2);
solver.solve(&depth_w, CG_ITERS).0
} else {
u
}
} else {
depth_w
};
let max_d = (n - 1) as f32;
let up = GuidedFilter::new(g, dw, dh, 2, 1e-2);
let depth: Vec<f32> = if k == 1 {
up.filter(&depth_w)
} else {
let (a, b) = up.coeffs(&depth_w);
let a = upsample_bilinear(&a, dw, dh, w, h, k);
let b = upsample_bilinear(&b, dw, dh, w, h, k);
(0..w * h).map(|i| a[i] * guide[i] + b[i]).collect()
};
let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect();
let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) };
DepthMap { depth, conf, w, h }
}
#[cfg(test)]
mod tests {
use super::*;
fn lcg(seed: &mut u64) -> f32 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((*seed >> 33) % 10000) as f32 / 10000.0
}
#[test]
fn box_filter_matches_the_naive_window() {
let (w, h, r) = (23, 17, 3);
let mut s = 5u64;
let src: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let m = BoxFilter::new(w, h, r).mean(&src);
for y in 0..h {
for x in 0..w {
let (mut acc, mut cnt) = (0.0, 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
acc += src[yy * w + xx];
cnt += 1.0;
}
}
assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})");
}
}
}
#[test]
fn guided_filter_with_a_flat_guide_is_a_double_box_mean() {
let (w, h) = (40, 30);
let mut s = 9u64;
let p: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p);
let bf = BoxFilter::new(w, h, 2);
let m = bf.mean(&bf.mean(&p));
assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4));
}
#[test]
fn ring_difference_is_zero_mean_and_sees_texture() {
let taps = rdf_taps(1, 3);
assert!(taps.iter().map(|t| t.2).sum::<f32>().abs() < 1e-6);
let (w, h) = (32, 32);
assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6));
let check: Vec<f32> = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect();
assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1);
let (m, dw, dh) = block_mean(&check, w, h, 2);
assert_eq!((dw, dh, m.len()), (16, 16, 256));
assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6));
let up = upsample_bilinear(&m, dw, dh, w, h, 2);
assert_eq!(up.len(), w * h);
}
#[test]
fn peak_search_interpolates_between_layers() {
// A profile peaking between layers 2 and 3 (log-parabola), one flat profile.
let mut t = PeakTracker::new(2);
for m in 0..6 {
let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp();
t.push(&[v, 0.1]);
}
let (d, c) = t.finish(0.0);
assert!((d[0] - 2.4).abs() < 0.05, "{d:?}");
assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}");
}
#[test]
fn smoothing_fills_holes_and_respects_edges() {
let (w, h) = (40, 20);
// A guide with a hard edge at x = 20; data known only at two pixels, one per side.
let guide: Vec<f32> = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect();
let mut d = vec![0.0f32; w * h];
let mut wd = vec![1e-4f32; w * h];
(d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0);
(d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0);
let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04);
solver.set_weights(&wd);
let (u, rel) = solver.solve(&d, CG_ITERS);
assert!(rel < 1e-4, "residual {rel}");
// Each side follows its own sample (the data floor on the unknown pixels pulls a little
// toward zero), and the edge keeps them apart.
assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]);
assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]);
assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]);
// The sweeps alone leave the holes near zero; the solve fills them.
let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0);
assert!(guess[3 * w + 15] < 0.5);
assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5);
}
/// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth
/// map says so, sub-layer in between, with high confidence on the texture.
#[test]
fn depth_from_focus_finds_the_sharp_layer() {
let (w, h) = (96, 48);
let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 };
let flat = 0.5;
let layers: Vec<Vec<f32>> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect();
let guide: Vec<f32> = (0..w * h).map(|i| texture(i % w, i / w)).collect();
let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect();
let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default());
assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h));
for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] {
let d = dm.depth[24 * w + x];
assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}");
assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]);
}
assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v)));
// Degenerate inputs are fine.
assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]);
// A guide or a plane of the wrong length is no input: the empty map, never a panic.
let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty);
let short = &refs[0][..w * h - 1];
assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty);
assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1);
let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default());
assert!(one.depth.iter().all(|v| *v == 0.0));
}
}
+42 -11
View File
@@ -26,6 +26,7 @@ pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
pub mod dff;
pub mod direct;
mod distort;
mod distort2;
@@ -552,7 +553,8 @@ impl FilterParams {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::BoxBlur { radius }
| FilterParams::SurfaceBlur { radius, .. }
| FilterParams::Median { radius }
@@ -937,7 +939,8 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
}
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation;
/// the input surface is never modified.
pub fn apply_in_with(
surface: &Surface,
params: &FilterParams,
@@ -969,13 +972,13 @@ pub fn apply_tiled(
tile: i32,
extent: Option<Rect>,
) -> Surface {
// Preserve the input if a checked Shape Blur allocation cannot be completed.
// Preserve the input if a checked filter allocation cannot be completed.
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
}
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
/// one tile's work) and progress (after each group of tiles). `None` when cancelled,
/// or when Shape Blur cannot allocate a tile.
/// or when a checked filter rejects geometry or cannot allocate a tile.
#[allow(clippy::too_many_arguments)]
pub fn apply_tiled_with(
surface: &Surface,
@@ -1000,15 +1003,27 @@ fn apply_tiled_with_impl(
tile: i32,
extent: Option<Rect>,
ctl: &photocraft_raster::Interrupt,
prepare_radial: bool,
prepare_fast_paths: bool,
) -> Option<Surface> {
if ctl.cancelled() {
return None;
}
if let FilterParams::UnsharpMask { amount, radius, threshold } = *params
&& (!sharpen::exact::valid_params(amount, radius, threshold)
|| !sharpen::exact::valid_area(area)
|| !sharpen::exact::valid_area(bounds)
|| extent.is_some_and(|e| !sharpen::exact::valid_area(e)))
{
return None;
}
let mut out = surface.clone();
if area.is_empty() {
return Some(out);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
}
if let Some(boxes) = blur::box_widths(params) {
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
}
@@ -1016,15 +1031,15 @@ fn apply_tiled_with_impl(
return result;
}
// The public tile override must not create a zero-step loop or an
// unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile };
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
// Prepare the immutable sample transforms once for all output tiles. If the
// optional table allocation fails, use the existing sampler; only a real
// cancellation returns None. Source reads keep their upstream semantics.
let radial = match params {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => {
blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl)
}
_ => None,
@@ -1033,6 +1048,17 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
return None;
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
@@ -1090,7 +1116,10 @@ fn apply_tiled_with_impl(
None if ctl.cancelled() => None,
None => Some(kernel(params, src, *t, &ctx)),
},
(None, None) => Some(kernel(params, src, *t, &ctx)),
(None, None) => match *params {
FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl),
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
};
@@ -1123,9 +1152,11 @@ fn apply_tiled_with_impl(
if ctl.cancelled() {
return None;
}
// Shape Blur can reject malformed geometry or an allocation failure.
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) {
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
{
return None;
}
for (t, data) in results {
+86 -2
View File
@@ -1277,11 +1277,18 @@ pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64;
/// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would
/// span more than [`MAX_WARP_PIXELS`].
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp, mask: Option<&Surface>) -> Option<(Surface, Option<Surface>)> {
let (solver, v, order) = deform(src, bounds, w, ITERATIONS);
// No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the
// empty mesh would drop every pixel of the layer.
if solver.mesh.tris.is_empty() {
return Some(src.clone());
return Some((src.clone(), mask.cloned()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &v {
@@ -1291,7 +1298,57 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
if area.is_nan() || area > MAX_WARP_PIXELS {
return None;
}
Some(render(src, bounds, &solver.mesh, &v, &order, interp))
let out = render(src, bounds, &solver.mesh, &v, &order, interp);
Some((out, mask.map(|m| render_mask(m, &solver.mesh, &v, &order, interp))))
}
/// Moves a one-channel layer mask with a deformed mesh: the mask under the mesh is lifted,
/// deformed like the layer's pixels and laid over the rest. The area the mesh leaves takes the
/// mask's default value (as [`crate::warp::warp_mesh_gray`] leaves it); outside the mesh the
/// mask keeps its values.
pub fn render_mask(mask: &Surface, mesh: &PuppetMesh, deformed: &[[f64; 2]], order: &[usize], interp: Interp) -> Surface {
let mut out = mask.clone();
if mesh.tris.is_empty() || deformed.len() != mesh.verts.len() || mask.format().channels() != 1 {
return out;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &mesh.verts {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return out;
}
// The mesh's source area, a pixel wider so its edge samples stay inside.
let lim = f64::from(1 << 20);
let src = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
if src.is_empty() {
return out;
}
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let fmt = mask.format();
let mut tmp = Surface::new(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true));
tmp.write_region(src, &mask.read_region(src).iter().flat_map(|g| [*g, 1.0]).collect::<Vec<f32>>());
let tris: Vec<[usize; 3]> = order.iter().filter_map(|&t| mesh.tris.get(t).copied()).collect();
// Lift: where the undeformed mesh covers the mask, the mask takes its default value.
let still: Vec<([f64; 2], [f64; 2])> = mesh.verts.iter().map(|s| (*s, *s)).collect();
let lifted = crate::warp::warp_triangles(&tmp, src, &still, &tris, Interp::Nearest);
let b = lifted.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = lifted.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| default * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
// Lay the deformed mask over what is left.
let moved: Vec<([f64; 2], [f64; 2])> = deformed.iter().zip(&mesh.verts).map(|(d, s)| (*d, *s)).collect();
let warped = crate::warp::warp_triangles(&tmp, src, &moved, &tris, interp);
let b = warped.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = warped.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
out.prune();
out
}
#[cfg(test)]
@@ -1434,6 +1491,33 @@ mod tests {
assert!(!near.content_bounds().is_empty());
}
#[test]
fn a_linked_mask_moves_with_the_mesh_and_leaves_its_default_behind() {
// #4910. A hide-all mask (default 0) that reveals the bar's top (y 10..30): moved down by
// 8, the revealed patch moves along, what it left is hidden again, and the mask away
// from the puppet keeps its values.
let s = blob(SampleType::U8);
let b = s.content_bounds();
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.0]);
mask.fill_rect(Rect::new(20, 10, 36, 30), &[1.0]);
mask.fill_rect(Rect::new(90, 90, 94, 94), &[0.5]);
let w = PuppetWarp {
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [28.0, 38.0], rotate: None, depth: 0 }],
mode: PuppetMode::Rigid,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let (_, moved) = puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, Some(&mask)).expect("warps");
let m = moved.expect("a mask in, a mask out");
let at = |x, y| m.sample_channel(x, y, 0);
assert!(at(28, 20) > 0.99 && at(28, 36) > 0.99, "{sample:?}: the revealed patch moved down");
assert!(at(28, 15) < 0.01, "{sample:?}: its old top is hidden again");
assert!((at(91, 91) - 0.5).abs() <= 1.0 / 255.0, "{sample:?}: away from the mesh nothing changes");
assert!(puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, None).expect("warps").1.is_none());
}
}
#[test]
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
+33 -29
View File
@@ -117,49 +117,53 @@ pub fn sponge(c: [f32; 3], amount: f32, saturate: bool, vibrance: bool) -> [f32;
pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
if ow == 0 || oh == 0 || ch == 0 {
return Vec::new();
}
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize;
let i = (y * w + x) * ch;
px.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) {
*d += s * kv;
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
+17 -4
View File
@@ -7,9 +7,10 @@
//! `γ e^{−β‖z_m − z_n‖²} / dist(m, n)` (γ = 50, β from the mean squared neighbour difference).
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::{DEFAULT_REG, Gmm};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -33,6 +34,13 @@ fn is_fg(t: u8) -> bool {
/// Runs `iters` GrabCut iterations, updating the probable labels of `trimap` in place. Returns
/// the final (foreground, background) models, or `None` if either side has no pixels.
pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, Gmm)> {
grabcut_with(img, trimap, iters, &Interrupt::NONE).unwrap_or_default()
}
/// [`grabcut`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked each iteration
/// and inside its min cut. `trimap` is then partly updated.
pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Interrupt) -> Result<Option<(Gmm, Gmm)>, Cancelled> {
let cancel = || ctl.cancelled();
let beta = contrast_beta(img);
let fixed: Vec<u8> = trimap
.iter()
@@ -44,17 +52,22 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
.collect();
let mut models: Option<(Gmm, Gmm)> = None;
for _ in 0..iters.max(1) {
ctl.check()?;
let (mut fs, mut bs) = (Vec::new(), Vec::new());
for (p, t) in img.px.iter().zip(trimap.iter()) {
if is_fg(*t) { fs.push(*p) } else { bs.push(*p) }
}
let (fs, bs) = (subsample(&fs, MAX_SAMPLES), subsample(&bs, MAX_SAMPLES));
let (fg, bg) = match &models {
None => (Gmm::fit(&fs, K, DEFAULT_REG)?, Gmm::fit(&bs, K, DEFAULT_REG)?),
None => match (Gmm::fit(&fs, K, DEFAULT_REG), Gmm::fit(&bs, K, DEFAULT_REG)) {
(Some(f), Some(b)) => (f, b),
_ => return Ok(None),
},
Some((f, b)) => (f.refit(&fs).unwrap_or_else(|| f.clone()), b.refit(&bs).unwrap_or_else(|| b.clone())),
};
let (cf, cb) = data_costs(img, &fg, &bg);
let cut = grid_cut(img, &cf, &cb, &fixed, GAMMA, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
@@ -68,7 +81,7 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
break;
}
}
models
Ok(models)
}
/// Object Selection (rectangle mode): GrabCut initialised from `rect` (outside = background,
+42 -9
View File
@@ -35,7 +35,7 @@ pub mod slic;
pub mod subject;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Cancelled, Interrupt, Surface};
pub use crate::selection::Region;
use gmm::Gmm;
@@ -545,6 +545,22 @@ pub fn trim_region(r: Region) -> Option<Region> {
/// pixels wide around the upsampled boundary, using the given colour models, tile by tile.
/// The result is anti-aliased by one pixel.
pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], lw: usize, lh: usize, models: Option<(&Gmm, &Gmm)>) -> Option<Region> {
finish_region_with(sampler, window, step, low, lw, lh, models, &Interrupt::NONE).unwrap_or_default()
}
/// [`finish_region`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked before each
/// band tile and inside its min cut.
#[allow(clippy::too_many_arguments)]
pub fn finish_region_with(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
lw: usize,
lh: usize,
models: Option<(&Gmm, &Gmm)>,
ctl: &Interrupt,
) -> Result<Option<Region>, Cancelled> {
let (w, h) = (window.width() as usize, window.height() as usize);
let mut mask = vec![0u8; w * h];
if step <= 1 {
@@ -582,16 +598,28 @@ pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bo
#[cfg(target_arch = "wasm32")]
mask.chunks_mut(w).enumerate().for_each(|(y, r)| fill(y, r));
if let Some((fg, bg)) = models {
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask);
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask, ctl)?;
}
}
antialias_u8(&mut mask, w, h);
trim_region(Region { bbox: window, mask })
Ok(trim_region(Region { bbox: window, mask }))
}
/// Full-resolution graph cut restricted to the band blocks (see [`finish_region`]).
#[allow(clippy::too_many_arguments)]
fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], band: &[bool], lw: usize, lh: usize, fg: &Gmm, bg: &Gmm, mask: &mut [u8]) {
fn refine_band(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
band: &[bool],
lw: usize,
lh: usize,
fg: &Gmm,
bg: &Gmm,
mask: &mut [u8],
ctl: &Interrupt,
) -> Result<(), Cancelled> {
const T: i32 = 192;
const HALO: i32 = 12;
let w = window.width() as usize;
@@ -612,7 +640,9 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
ty += T;
}
let run = |t: &Rect| -> (Rect, Vec<bool>) {
let cancel = || ctl.cancelled();
let run = |t: &Rect| -> Result<(Rect, Vec<bool>), Cancelled> {
ctl.check()?;
let r = t.inflate(HALO).intersect(&window);
let img = sampler.rgb(r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
@@ -635,7 +665,8 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
let beta = contrast_beta(&img);
let cut = grid_cut(&img, &cfg, &cbg, &fixed, 50.0, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
@@ -643,15 +674,16 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
let sx = (t.x0 - r.x0) as usize;
core.extend_from_slice(&cut[sy * rw + sx..sy * rw + sx + cw]);
}
(*t, core)
Ok((*t, core))
};
// A cancelled tile ends the collect; tiles not yet started see `ctl` and return at once.
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<bool>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
tiles.par_iter().map(run).collect::<Result<_, _>>()?
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect();
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect::<Result<_, _>>()?;
for (t, core) in results {
let cw = t.width() as usize;
for (yy, row) in core.chunks_exact(cw).enumerate() {
@@ -661,6 +693,7 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
}
Ok(())
}
/// Deterministic pseudo-random numbers (xorshift) for sampling and tests.
+13 -5
View File
@@ -23,6 +23,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
@@ -35,13 +36,20 @@ const SIGMA_SPA: f32 = 0.25;
/// Selects the most salient object on the canvas.
pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
select_subject_with(sampler, canvas, &Interrupt::NONE).unwrap_or_default()
}
/// [`select_subject`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked between
/// stages and inside every min cut (the GrabCut iterations and the full-resolution re-cut).
pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) -> Result<Option<Region>, Cancelled> {
if canvas.width() < 4 || canvas.height() < 4 {
return None;
return Ok(None);
}
let step = super::scale_for(canvas, WORK_PX);
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
let sal = saliency(&img);
ctl.check()?;
let t = otsu(&sal).clamp(0.15, 0.85);
let mut trimap = vec![PR_BG; w * h];
let mut any_fg = false;
@@ -60,15 +68,15 @@ pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
}
}
if !any_fg {
return None;
return Ok(None);
}
let (fg, bg) = grabcut::grabcut(&img, &mut trimap, 6)?;
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
if !low.iter().any(|v| *v) {
return None;
return Ok(None);
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Per-pixel saliency in `0..=1` (see the module docs).
+81
View File
@@ -565,6 +565,87 @@ fn select_subject_finds_centred_object() {
assert!(v > 0.9, "IoU {v}");
}
/// Counts full-resolution reads; the working copy goes through `rgb_scaled` uncounted, so each
/// read is one band tile of the full-resolution re-cut.
struct TileReads<'a>(ImageSampler<'a>, std::sync::atomic::AtomicUsize);
impl Sampler for TileReads<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.0.rgba(r)
}
fn rgb_scaled(&self, r: Rect, step: usize) -> RgbImage {
self.0.rgb_scaled(r, step)
}
}
#[test]
fn finish_region_cancel_stops_inside_the_band_cut() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
// One 180-pixel window is one band tile, and nothing asks after the band re-cut, so a cancel
// seen after the tile's own check can only have been seen inside its min cut.
let (n, step) = (180, 3);
let (img, truth) = disc_scene(n, n, 90.0, 90.0, 55.0, 4);
let s = ImageSampler { img: &img, origin: (0, 0) };
let ln = n / step;
let low: Vec<bool> = (0..ln * ln).map(|i| truth[((i / ln) * step + 1) * n + (i % ln) * step + 1]).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&truth).partition(|(_, t)| **t);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 5, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let window = Rect::new(0, 0, n as i32, n as i32);
let polls = AtomicUsize::new(0);
let finish = |cancel_from: usize| {
polls.store(0, SeqCst);
let cancel = || polls.fetch_add(1, SeqCst) + 1 >= cancel_from;
finish_region_with(&s, window, step, &low, ln, ln, Some((&fg, &bg)), &Interrupt::cancel_only(&cancel))
};
let reg = finish(usize::MAX).unwrap().unwrap();
assert!(polls.load(SeqCst) >= 2, "the cut polls too ({} polls)", polls.load(SeqCst));
assert!(iou(&reg, &truth, n, n) > 0.95);
assert_eq!(finish_region(&s, window, step, &low, ln, ln, Some((&fg, &bg))), Some(reg));
// Cancelled at the cut's first poll (the second in all): no region.
assert_eq!(finish(2), Err(Cancelled));
assert_eq!(polls.load(SeqCst), 2);
assert_eq!(finish(1), Err(Cancelled), "cancelled before the tile");
}
#[test]
fn select_subject_cancelled_in_the_band_recut_reads_no_more_tiles() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
let (w, h) = (3000, 2000);
let (img, truth) = disc_scene(w, h, 1500.0, 1000.0, 700.0, 6);
let canvas = Rect::new(0, 0, w as i32, h as i32);
let sampler = || TileReads(ImageSampler { img: &img, origin: (0, 0) }, AtomicUsize::new(0));
// Not cancelled: the same region as `select_subject`, and the band cuts themselves ask
// (more polls once tiles are read than the one check before each tile).
let s = sampler();
let band_polls = AtomicUsize::new(0);
let count = || {
if s.1.load(SeqCst) > 0 {
band_polls.fetch_add(1, SeqCst);
}
false
};
let reg = subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&count)).unwrap().unwrap();
let tiles = s.1.load(SeqCst);
assert!(tiles > 4, "{tiles} band tiles");
assert!(band_polls.load(SeqCst) > tiles, "{} polls over {tiles} tiles", band_polls.load(SeqCst));
assert!(iou(&reg, &truth, w, h) > 0.9);
assert_eq!(subject::select_subject(&sampler(), canvas), Some(reg));
// Cancelled once the first tile is read: the run stops there instead of re-cutting every
// tile. One thread, so no other tile is already in flight.
let s = sampler();
let cancel = || s.1.load(SeqCst) > 0;
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let r = pool.install(|| subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&cancel)));
assert_eq!(r, Err(Cancelled));
assert_eq!(s.1.load(SeqCst), 1, "tiles read after the cancel");
}
#[test]
fn saliency_highlights_the_object() {
let (img, truth) = disc_scene(120, 90, 60.0, 45.0, 22.0, 8);
+12 -6
View File
@@ -6,6 +6,17 @@ use crate::Ctx;
use crate::blur::{conv_sep, gaussian_kernel};
use crate::image::Image;
pub(crate) mod exact;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod fft;
#[cfg(test)]
mod tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod fft_bench;
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
@@ -24,12 +35,7 @@ fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
exact::run(src, out, ctx, amount, radius, threshold, &photocraft_raster::Interrupt::NONE).unwrap_or_default()
}
pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec<f32> {
+134
View File
@@ -0,0 +1,134 @@
//! Exact separable convolution for Unsharp Mask: stream one source row and reuse
//! the blurred output for the final arithmetic. Each sample retains its tap order.
use crate::image::premultiply;
use crate::{Ctx, Image};
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
const MAX_SAMPLES: usize = 128 * 1024 * 1024;
// The filter dialog caps radius at 1000. Preserve Details resampling can ask
// for more; permit that while the unchanged kernel's i32 square is safe.
const MAX_RADIUS: f32 = 15_446.0;
pub(crate) fn valid_params(amount: f32, radius: f32, threshold: f32) -> bool {
amount.is_finite() && threshold.is_finite() && radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius)
}
pub(crate) fn halo(radius: f32) -> i32 {
if radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) { (radius * 3.0).ceil() as i32 + 1 } else { 0 }
}
pub(crate) fn valid_area(r: Rect) -> bool {
r.x0 <= r.x1
&& r.y0 <= r.y1
&& r.width() <= 300_000
&& r.height() <= 300_000
&& [r.x0, r.x1, r.y0, r.y1].iter().all(|v| (-1_000_000..=1_000_000).contains(v))
}
fn zeros(len: usize) -> Option<Vec<f32>> {
if len > MAX_SAMPLES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0.0);
Some(data)
}
/// Checked contiguous row copy, with the same zero extension as Image::get.
fn read_row(src: &Image, x0: i32, x1: i32, y: i32, dst: &mut [f32]) -> Option<()> {
dst.fill(0.0);
if y < src.rect.y0 || y >= src.rect.y1 {
return Some(());
}
let (lo, hi) = (x0.max(src.rect.x0), x1.min(src.rect.x1));
if lo >= hi {
return Some(());
}
let n = src.ch;
let count = usize::try_from(i64::from(hi) - i64::from(lo)).ok()?.checked_mul(n)?;
let offset = usize::try_from(i64::from(lo) - i64::from(x0)).ok()?.checked_mul(n)?;
let row = usize::try_from(i64::from(y) - i64::from(src.rect.y0)).ok()?;
let col = usize::try_from(i64::from(lo) - i64::from(src.rect.x0)).ok()?;
let start = row.checked_mul(src.rect.width() as usize)?.checked_add(col)?.checked_mul(n)?;
dst.get_mut(offset..offset.checked_add(count)?)?.copy_from_slice(src.data.get(start..start.checked_add(count)?)?);
Some(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32, ctl: &Interrupt) -> Option<Vec<f32>> {
if ctl.cancelled() || !valid_params(amount, radius, threshold) || !valid_area(out) || !valid_area(src.rect) {
return None;
}
let n = src.ch;
if n == 0 || n > crate::fxutil::MAXC {
return None;
}
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(n)? {
return None;
}
if out.is_empty() {
return Some(Vec::new());
}
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let (ow, oh) = (out.width() as usize, out.height() as usize);
let row_len = ow.checked_mul(n)?;
let th = oh.checked_add(2 * r)?;
let ww = ow.checked_add(2 * r)?;
let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?);
let mut row = zeros(ww.checked_mul(n)?)?;
let mut tmp = zeros(row_len.checked_mul(th)?)?;
let mut result = zeros(row_len.checked_mul(oh)?)?;
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?;
premultiply(&mut row, n, ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
}
let (amount, threshold) = (amount / 100.0, threshold / 255.0);
let cc = n - usize::from(ctx.alpha);
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
for (i, weight) in kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
let originals = row.get_mut(..row_len)?;
read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?;
for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if ctx.alpha && *original.last()? <= 0.0 {
blurred.copy_from_slice(original);
continue;
}
let a = if ctx.alpha { *blurred.last()? } else { 1.0 };
for (b, o) in blurred.iter_mut().zip(original).take(cc) {
// Keep the original unpremultiply cut-off and division, followed by
// the threshold epsilon and inclusive comparison, at every depth.
let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let d = o - b_value;
*b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o };
}
if ctx.alpha {
*blurred.last_mut()? = *original.last()?;
}
}
}
Some(result)
}
+325
View File
@@ -0,0 +1,325 @@
//! Wide exact-kernel Gaussian convolution using FFTs. Summation order changes:
//! positive thresholds and unstable samples retain the sequential f32 fallback.
use crate::{FilterParams, Image};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use rayon::prelude::*;
use rustfft::{Fft, FftPlanner, num_complex::Complex};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
const MAX_BYTES: usize = 1 << 30;
const MAX_SIDE: usize = 32_768;
const STRIPE: usize = 32;
type Number = Complex<f64>;
fn buffer<T: Clone>(len: usize, value: T) -> Option<Vec<T>> {
if len.checked_mul(std::mem::size_of::<T>())? > MAX_BYTES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, value);
Some(data)
}
struct Plan {
size: usize,
forward: Arc<dyn Fft<f64>>,
inverse: Arc<dyn Fft<f64>>,
spectrum: Vec<Number>,
scratch: usize,
}
impl Plan {
fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner<f64>) -> Option<Self> {
let size = needed.checked_next_power_of_two()?;
if size > MAX_SIDE || kernel.len() > size {
return None;
}
let forward = planner.plan_fft_forward(size);
let inverse = planner.plan_fft_inverse(size);
let scratch = forward.get_inplace_scratch_len().max(inverse.get_inplace_scratch_len());
let mut spectrum = buffer(size, Number::default())?;
for (dst, &weight) in spectrum.iter_mut().zip(kernel) {
dst.re = f64::from(weight) / size as f64;
}
forward.process_with_scratch(&mut spectrum, &mut buffer(scratch, Number::default())?);
Some(Self { size, forward, inverse, spectrum, scratch })
}
fn work(&self) -> Option<(Vec<Number>, Vec<Number>)> {
Some((buffer(self.size, Number::default())?, buffer(self.scratch, Number::default())?))
}
fn convolve(&self, data: &mut [Number], scratch: &mut [Number]) -> Option<()> {
if data.len() != self.size || scratch.len() < self.scratch {
return None;
}
self.forward.process_with_scratch(data, scratch);
for (d, k) in data.iter_mut().zip(&self.spectrum) {
*d *= k;
}
self.inverse.process_with_scratch(data, scratch);
Some(())
}
}
// Bound the complete temporary working set, including all concurrently live
// column gathers, originals, FFT scratch and result stripes. Document tiles are
// excluded, just as in the existing tile scheduler's scratch budget.
fn group_size(resident: usize, worker: usize, count: usize, workers: usize) -> Option<usize> {
let group = MAX_BYTES.checked_sub(resident)?.checked_div(worker.max(1))?.min(workers).min(count);
(group > 0).then_some(group)
}
/// `None` declines the optimization, `Some(None)` propagates cancellation.
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply(
surface: &Surface,
params: &FilterParams,
area: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
let FilterParams::UnsharpMask { amount, radius, threshold } = *params else { return None };
// At a positive threshold, even a tiny sum-order difference can switch the
// entire sharpen contribution. Zero/negative thresholds always keep it.
if !(64.0..=1000.0).contains(&radius)
|| threshold > 0.0
|| amount == 0.0
|| !super::exact::valid_params(amount, radius, threshold)
|| !super::exact::valid_area(area)
|| area.is_empty()
|| u64::from(area.width()) * u64::from(area.height()) < 65_536
|| !(1..=crate::fxutil::MAXC).contains(&surface.channels())
|| selection.is_some_and(|s| s.channels() == 0)
|| extent.is_some_and(|e| !super::exact::valid_area(e) || area.intersect(&e) != area)
{
return None;
}
if ctl.cancelled() {
return Some(None);
}
match run(surface, area, amount, radius, selection, extent, ctl) {
Some(out) => Some(Some(out)),
None if ctl.cancelled() => Some(None),
None => None,
}
}
#[allow(clippy::too_many_arguments)]
fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Option<&Surface>, extent: Option<Rect>, ctl: &Interrupt) -> Option<Surface> {
let (w, h, n) = (area.width() as usize, area.height() as usize, surface.channels());
let alpha = surface.format().alpha;
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let reach = i32::try_from(r).ok()?;
let (x0, x1) = (area.x0.checked_sub(reach)?, area.x1.checked_add(reach)?);
let (y0, y1) = (area.y0.checked_sub(reach)?, area.y1.checked_add(reach)?);
let (read_x0, read_x1, read_y0, read_y1) = match extent {
Some(e) => (x0.max(e.x0), x1.min(e.x1), y0.max(e.y0), y1.min(e.y1)),
None => (x0, x1, y0, y1),
};
let rows = usize::try_from(i64::from(read_y1) - i64::from(read_y0)).ok()?;
let read_width = usize::try_from(i64::from(read_x1) - i64::from(read_x0)).ok()?;
let row_len = w.checked_mul(n)?;
let samples = row_len.checked_mul(rows)?;
let resident = samples.checked_mul(4)?;
let mut planner = FftPlanner::<f64>::new();
let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
// A conservative allowance includes FFT plans/twiddles in addition to spectra.
let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?;
let resident = resident.checked_add(plans)?;
let workers = rayon::current_num_threads().max(1);
let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?;
let vertical_worker = STRIPE
.checked_mul(rows)?
.checked_mul(n)?
.checked_mul(4)?
.checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)?
.checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?;
let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?;
let mut tmp = buffer(samples, 0.0f32)?;
let finished = AtomicUsize::new(0);
// Read each needed real row once. Outside a repeated extent, horizontal
// results are repeated lazily by the vertical gather, not transformed again.
for band in tmp.chunks_mut(row_len.checked_mul(group)?) {
let first = finished.load(Ordering::Relaxed);
let ok: Option<Vec<()>> = band
.par_chunks_exact_mut(row_len)
.enumerate()
.map(|(offset, dst)| {
if ctl.cancelled() {
return None;
}
let y = read_y0.checked_add(i32::try_from(first.checked_add(offset)?).ok()?)?;
let mut row = buffer(read_width.checked_mul(n)?, 0.0f32)?;
// Reservation above covers resize, so the reusable raster read cannot allocate.
surface.read_region_into(Rect::new(read_x0, y, read_x1, y.checked_add(1)?), &mut row);
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in row.chunks_exact_mut(n) {
if px.iter().any(|v| !v.is_finite() || v.abs() > 1e6) || (alpha && !px.last().is_some_and(|a| (0.0..=1.0).contains(a))) {
return None;
}
let a = if alpha { *px.last()? } else { 1.0 };
for (c, v) in px.iter_mut().enumerate() {
if alpha && c + 1 < n {
*v *= a;
}
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(v.abs()));
}
}
let (mut data, mut scratch) = horizontal.work()?;
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(w + 2 * r).enumerate() {
let sx = x0.checked_add(i32::try_from(i).ok()?)?.clamp(read_x0, read_x1.checked_sub(1)?);
let start = usize::try_from(sx - read_x0).ok()?.checked_mul(n)?;
let px = row.get(start..start.checked_add(n)?)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
horizontal.convolve(&mut data, &mut scratch)?;
for (px, d) in dst.chunks_exact_mut(n).zip(data.get(2 * r..2 * r + w)?) {
*px.get_mut(channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = px.get_mut(channel + 1) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
finished.fetch_add(1, Ordering::Relaxed);
Some(())
})
.collect();
ok?;
ctl.progress(0.5 * finished.load(Ordering::Relaxed) as f32 / rows as f32);
if ctl.cancelled() {
return None;
}
}
let mut out = surface.clone();
let count = w.div_ceil(STRIPE);
for first in (0..count).step_by(group) {
let end = first.checked_add(group)?.min(count);
let results: Option<Vec<_>> = (first..end)
.into_par_iter()
.map(|index| {
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(w.checked_sub(x)?);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in tmp.chunks_exact(row_len).enumerate() {
if y % 64 == 0 && ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = vertical.work()?;
for (c, samples) in columns.chunks_exact(rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?);
let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
vertical.convolve(&mut data, &mut scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
}
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
if let Some(sel) = selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha);
}
Some((rect, result))
})
.collect();
for (rect, data) in results? {
if ctl.cancelled() {
return None;
}
out.try_write_region(rect, &data).ok()?;
}
ctl.progress(0.5 + 0.48 * end as f32 / count as f32);
}
if ctl.cancelled() {
return None;
}
out.prune();
if ctl.cancelled() {
return None;
}
ctl.progress(1.0);
Some(out)
}
#[cfg(test)]
mod tests;
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::sharpen::tests::{reference, source};
use crate::{Ctx, image::Image};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
fn compare(old: &Surface, new: &Surface, area: Rect) {
let tolerance = match old.format().sample {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 2.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5,
};
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let limit = if old.format().sample == SampleType::F32 { tolerance * (1.0 + a.abs()) } else { tolerance };
assert!((a - b).abs() <= limit, "{a} != {b}, tolerance {limit}");
}
}
#[test]
fn fft_preserves_gaussian_quality_at_every_depth_and_model() {
let area = Rect::new(-3, 2, 10, 13);
for mode in [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
] {
for depth in SampleType::ALL {
for alpha in [false, true] {
let s = source(area, PixelFormat::new(mode, depth, alpha));
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [3.25, 64.0] {
let src = Image::read_clamped(&s, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, radius, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
let actual = run(&s, area, 500.0, radius, None, Some(area), &Interrupt::NONE).unwrap();
compare(&expected, &actual, area);
if alpha {
for (a, b) in s.read_region(area).chunks_exact(s.channels()).zip(actual.read_region(area).chunks_exact(s.channels())) {
assert_eq!(a.last(), b.last());
if a.last() == Some(&0.0) {
assert_eq!(a, b);
}
}
}
}
}
}
}
}
#[test]
fn fft_keeps_partial_regions_selections_and_edge_policies() {
let extent = Rect::new(-7, -9, 35, 39);
let area = Rect::new(-3, 2, 19, 23);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
for depth in SampleType::ALL {
let s = source(extent, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
for edge in [None, Some(extent)] {
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, extent, Some(&sel), edge, 256);
let actual = run(&s, area, 150.0, 64.0, Some(&sel), edge, &Interrupt::NONE).unwrap();
compare(&expected, &actual, extent);
}
}
}
#[test]
fn fft_falls_back_for_thresholds_and_unstable_samples() {
let area = Rect::new(0, 0, 256, 256);
let mut s = source(area, PixelFormat::RGBA32F);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 8.0 };
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
s.fill_rect(Rect::new(0, 0, 1, 1), &[f32::NAN, 0.5, 0.5, 1.0]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, 1e-8]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, -0.1]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
}
#[test]
fn fft_cancellation_discards_partial_work_and_bounds_scratch() {
let area = Rect::new(0, 0, 256, 256);
let s = source(area, PixelFormat::RGBA8);
let original = s.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 300.0, threshold: 0.0 };
let flag = std::sync::atomic::AtomicBool::new(false);
let cancel = || flag.load(Ordering::Relaxed);
let progress = |_| flag.store(true, Ordering::Relaxed);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &progress)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
flag.store(false, Ordering::Relaxed);
let after_writes = |value| {
if value > 0.55 {
flag.store(true, Ordering::Relaxed);
}
};
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &after_writes)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
assert_eq!(group_size(MAX_BYTES, 1, 1, 1), None);
assert_eq!(group_size(0, usize::MAX, 1, 1), None);
assert!(Plan::new(usize::MAX, &[1.0], &mut FftPlanner::new()).is_none());
assert!(buffer::<f32>(usize::MAX, 0.0).is_none());
}
#[test]
fn positive_threshold_dispatch_retains_exact_pixel_decisions() {
let area = Rect::new(-3, 2, 253, 258);
for depth in SampleType::ALL {
let s = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 500.0, radius: 64.0, threshold: 8.0 };
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, area, None, Some(area), 256);
let actual = crate::apply_in(&s, &p, area, area, None, area);
assert_eq!(expected.to_interleaved(area), actual.to_interleaved(area));
}
}
#[test]
fn fft_handles_maximum_radius_and_hdr_alpha_channel_pairs() {
let area = Rect::new(0, 0, 1, 1);
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
s.fill_rect(area, &[0.5]);
let ctx = Ctx { bounds: area, mode: ColorMode::Grayscale, alpha: false };
let src = Image::read_clamped(&s, area.inflate(3001), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 1000.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 1000.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
let area = Rect::new(-2, 3, 11, 14);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(area, &[1e6, -1e5, 1e-4, 0.02]);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let src = Image::read_clamped(&s, area.inflate(193), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 64.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 64.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
}
+112
View File
@@ -0,0 +1,112 @@
//! Opt-in benchmark of the adaptive production path against the frozen original.
use super::tests::{apply_reference, source};
use crate::FilterParams;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::time::Instant;
#[test]
#[ignore = "adaptive large-radius full-operation quality and performance; run alone"]
fn unsharp_fft_performance() {
let number = |name: &str, default| std::env::var(name).ok().and_then(|v| v.parse::<i32>().ok()).unwrap_or(default);
let (w, h) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1920), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1080));
let reps = number("PHOTOCRAFT_UNSHARP_REPS", 3);
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=5).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let surface = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "100:150:0,300:150:0,500:150:8".into());
for case in cases.split(',') {
let v: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (v[0], v[1], v[2]);
let params = FilterParams::UnsharpMask { amount, radius, threshold };
let baseline = || apply_reference(&surface, &params, area, area, None, Some(area), crate::auto_tile(&params, area));
let candidate = || crate::apply_in(&surface, &params, area, area, None, area);
for _ in 0..2 {
std::hint::black_box(baseline());
std::hint::black_box(candidate());
}
let mut before = Vec::new();
let mut after = Vec::new();
let mut max_error = 0.0f32;
let mut changed = 0usize;
let mut sum_error = 0.0f64;
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let t = Instant::now();
let output = f();
(output, t.elapsed().as_secs_f64() * 1000.0)
};
let ((old, a), (new, b)) = if rep % 2 == 0 {
(timed(&baseline), timed(&candidate))
} else {
let b = timed(&candidate);
(timed(&baseline), b)
};
before.push(a);
after.push(b);
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let e = (a - b).abs();
let limit = match depth {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 3.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5 * (1.0 + a.abs()),
};
assert!(e <= limit, "quality regression: {a} != {b} at radius {radius}, threshold {threshold}");
max_error = max_error.max(e);
if rep == 0 {
sum_error += f64::from(e);
changed += usize::from(a.to_bits() != b.to_bits());
}
}
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"experiment":"adaptive FFT same Gaussian, changed summation","width":w,"height":h,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"before_ms":before,"after_ms":after,"before_median_ms":median(&before),"after_median_ms":median(&after),"speedup":median(&before)/median(&after),"max_error":max_error,"mean_error":sum_error/(w as f64*h as f64*4.0),"changed_samples":changed})
);
}
}
#[test]
#[ignore = "4K wide-radius cancellation latency; run alone in release"]
fn unsharp_fft_cancel_latency() {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
let area = Rect::new(0, 0, 3840, 2160);
let surface = source(area, PixelFormat::RGBA8);
let input = surface.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 1000.0, threshold: 0.0 };
let mut latencies = Vec::new();
for _ in 0..5 {
let flag = AtomicBool::new(false);
let cancel_at = AtomicU64::new(0);
let started = Instant::now();
std::thread::scope(|scope| {
let timer = scope.spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
cancel_at.store(started.elapsed().as_micros() as u64, Ordering::Relaxed);
flag.store(true, Ordering::Release);
});
let cancel = || flag.load(Ordering::Acquire);
let result = crate::apply_in_with(&surface, &p, area, area, None, area, &photocraft_raster::Interrupt::cancel_only(&cancel));
let elapsed = started.elapsed().as_micros() as u64;
timer.join().unwrap();
assert!(result.is_none());
latencies.push(elapsed.saturating_sub(cancel_at.load(Ordering::Relaxed)) as f64 / 1000.0);
});
assert_eq!(surface.to_interleaved(area), input);
}
println!(
"{}",
serde_json::json!({"width":3840,"height":2160,"radius":1000,"cancel_after_ms":10,"cancel_return_latencies_ms":latencies,"scope":"native full-resolution algo dispatch, excludes GUI scheduling/proxy creation"})
);
}
+113
View File
@@ -0,0 +1,113 @@
//! Frozen exact Unsharp Mask from a28d7866, independent of production helpers.
use crate::Ctx;
use crate::image::{Image, premultiply, unpremultiply};
use photocraft_geom::Rect;
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
if sigma < 0.05 {
return vec![1.0];
}
let r = (sigma * 3.0).ceil() as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect()
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
let n = src.ch;
let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let th = oh + 2 * ry as usize;
// Premultiplied copy of the needed source window.
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
let ww = win.width() as usize;
let mut p = vec![0.0f32; ww * win.height() as usize * n];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
for c in 0..n {
p[o + c] = src.get(x, y, c);
}
}
}
premultiply(&mut p, n, alpha);
// Horizontal pass: (ow × th).
let mut tmp = vec![0.0f32; ow * th * n];
for ty in 0..th {
let row = &p[ty * ww * n..(ty + 1) * ww * n];
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
match n {
1 => conv_row::<1>(row, dst, kx),
2 => conv_row::<2>(row, dst, kx),
3 => conv_row::<3>(row, dst, kx),
4 => conv_row::<4>(row, dst, kx),
5 => conv_row::<5>(row, dst, kx),
_ => {
for ox in 0..ow {
let d = &mut dst[ox * n..(ox + 1) * n];
for (i, kv) in kx.iter().enumerate() {
let s = &row[(ox + i) * n..(ox + i + 1) * n];
for c in 0..n {
d[c] += s[c] * kv;
}
}
}
}
}
}
// Vertical pass.
let mut res = vec![0.0f32; ow * oh * n];
for oy in 0..oh {
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
for (i, kv) in ky.iter().enumerate() {
let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n];
for (dv, sv) in d.iter_mut().zip(s) {
*dv += sv * kv;
}
}
}
unpremultiply(&mut res, n, alpha);
res
}
// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize
// channels. The tap order is unchanged; unusual multichannel images keep the generic path.
fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
let pixels = src.as_chunks::<N>().0;
for (x, out) in dst.as_chunks_mut::<N>().0.iter_mut().enumerate() {
let mut acc = [0.0f32; N];
for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) {
for c in 0..N {
acc[c] += pixel[c] * weight;
}
}
*out = acc;
}
}
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
let mut res = src.crop(out);
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) {
if ctx.alpha && px[n - 1] <= 0.0 {
continue;
}
for c in 0..cc {
px[c] = f(px[c], bl[c]);
}
}
res
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
}
+434
View File
@@ -0,0 +1,434 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
use std::time::Instant;
#[path = "reference.rs"]
pub(super) mod reference;
pub(super) fn source(rect: Rect, fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
for y in rect.y0..rect.y1 {
let data: Vec<f32> = (rect.x0..rect.x1)
.flat_map(|x| {
(0..fmt.channels()).map(move |c| {
if fmt.alpha && c + 1 == fmt.channels() {
if (x + y).rem_euclid(17) == 0 { 0.0 } else { ((x ^ y).rem_euclid(255) + 1) as f32 / 255.0 }
} else {
let value = ((x * (7 + c as i32) + y * 13).rem_euclid(257)) as f32 / 256.0;
if fmt.sample == SampleType::F32 { 3.0 * value - 0.5 } else { value }
}
})
})
.collect();
s.write_region(Rect::new(rect.x0, y, rect.x1, y + 1), &data);
}
s
}
// Preserve the surrounding tiled workload as well as the old convolution: halo reads,
// automatic tiles, grouping, selection mixing, writes and pruning are timed in both paths.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_reference(
s: &Surface,
p: &crate::FilterParams,
area: Rect,
bounds: Rect,
sel: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
) -> Surface {
let crate::FilterParams::UnsharpMask { amount, radius, threshold } = *p else { panic!("unsharp params") };
let crate::Halo::Radius(halo) = p.halo_for(bounds) else { panic!("radius halo") };
let fmt = s.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
let tiles: Vec<_> = (area.y0..area.y1)
.step_by(tile as usize)
.flat_map(|y| (area.x0..area.x1).step_by(tile as usize).map(move |x| Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1))))
.collect();
let group = (crate::RESULT_BUDGET / ((tile as usize).pow(2) * fmt.channels() * 4)).max(workers()).max(1);
let finished = std::sync::atomic::AtomicUsize::new(0);
let ctl = Interrupt::NONE;
let run = |t: &Rect| {
let src = match extent {
Some(e) => Image::read_clamped(s, t.inflate(halo), e),
None => Image::read(s, t.inflate(halo)),
};
let mut data = reference::unsharp(&src, *t, &ctx, amount, radius, threshold);
if let Some(sel) = sel {
crate::mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * done as f32 / tiles.len().max(1) as f32);
(*t, data)
};
let mut out = s.clone();
for chunk in tiles.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = chunk.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
}
fn workers() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
#[test]
fn frozen_reference_matches_current_kernel() {
let out = Rect::new(-3, 2, 16, 15);
let src = Image { rect: out.inflate(13), ch: 4, data: (0..(out.width() + 26) * (out.height() + 26) * 4).map(|i| (i % 127) as f32 / 126.0).collect() };
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
for radius in [0.1, 0.6, 1.5, 4.0] {
assert_eq!(unsharp(&src, out, &ctx, 150.0, radius, 8.0), reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
fn same_bits(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len());
for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || a.is_nan() && b.is_nan(), "sample {i}: {a:?} ({:x}) != {b:?} ({:x})", a.to_bits(), b.to_bits());
}
}
#[test]
fn exact_kernel_across_models_depths_edges_and_thresholds() {
let modes = [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
];
for mode in modes {
for depth in SampleType::ALL {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, depth, alpha);
let area = Rect::new(-3, 2, 8, 7);
let mut surface = source(area, fmt);
if depth == SampleType::F32 {
// HDR, negative, signed zero, subnormal and nonfinite samples; colour is
// never clamped by this filter. Transparent hidden colour must survive.
let px: Vec<f32> = (0..fmt.channels()).map(|c| if alpha && c + 1 == fmt.channels() { 0.0 } else { -4.0 }).collect();
surface.fill_rect(Rect::new(0, 3, 1, 4), &px);
for (x, value) in [3.5, -0.0, f32::MIN_POSITIVE, f32::INFINITY, f32::NAN].into_iter().enumerate() {
let mut px = vec![value; fmt.channels()];
if alpha {
*px.last_mut().unwrap() = if x == 2 { 1e-8 } else { 1.0 };
}
surface.fill_rect(Rect::new(x as i32, 5, x as i32 + 1, 6), &px);
}
}
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [0.0, 0.049, 0.1, 0.6, 1.5, 4.0, 17.25] {
let src = Image::read_clamped(&surface, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
for (amount, threshold) in [(0.0, 0.0), (50.0, 0.0), (500.0, 8.0), (300.0, 255.0)] {
same_bits(&unsharp(&src, area, &ctx, amount, radius, threshold), &reference::unsharp(&src, area, &ctx, amount, radius, threshold));
}
// An incomplete halo has zero extension, even with alpha.
let src = Image::read(&surface, area);
let out = Rect::new(-5, 1, 10, 8);
same_bits(&unsharp(&src, out, &ctx, 150.0, radius, 8.0), &reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
}
}
}
#[test]
fn exact_threshold_boundary_and_degenerate_images() {
for (w, h, n) in [(1, 1, 1), (1, 7, 2), (9, 1, 3), (7, 5, 4), (2, 3, 5), (5, 2, 7)] {
let out = Rect::new(-2, 3, w - 2, h + 3);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: n % 2 == 0 };
let src = Image { rect: out, ch: n, data: (0..w * h * n as i32).map(|i| if i % 7 == 0 { -0.0 } else { (i % 17) as f32 / 16.0 }).collect() };
for radius in [0.0, 0.1, 0.75, 8.5, 64.0] {
let k = reference::gaussian_kernel(radius);
let blurred = reference::conv_sep(&src, out, &k, &k, ctx.alpha);
for (&o, &b) in src.data.iter().zip(&blurred).take(8) {
let boundary = ((o - b).abs() + 1e-6) * 255.0;
for threshold in [boundary, f32::from_bits(boundary.to_bits().saturating_sub(1)), f32::from_bits(boundary.to_bits() + 1)] {
same_bits(&unsharp(&src, out, &ctx, 500.0, radius, threshold), &reference::unsharp(&src, out, &ctx, 500.0, radius, threshold));
}
}
}
}
let empty = Image { rect: Rect::EMPTY, ch: 4, data: Vec::new() };
let ctx = Ctx { bounds: Rect::EMPTY, mode: ColorMode::Rgb, alpha: true };
assert!(unsharp(&empty, Rect::EMPTY, &ctx, 100.0, 1.0, 0.0).is_empty());
}
#[test]
fn exact_tiled_selected_outputs_and_cancellation() {
let bounds = Rect::new(-13, -7, 281, 271);
let area = Rect::new(-3, 5, 273, 265);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
sel.fill_rect(Rect::new(250, 250, 270, 265), &[1.0]);
for depth in SampleType::ALL {
let surface = source(bounds, PixelFormat::new(ColorMode::Rgb, depth, true));
for radius in [0.6, 3.0, 10.0] {
let p = crate::FilterParams::UnsharpMask { amount: 150.0, radius, threshold: 8.0 };
for extent in [None, Some(bounds)] {
let expected = apply_reference(&surface, &p, area, bounds, Some(&sel), extent, 256);
for tile in [63, 256, 512] {
let actual = crate::apply_tiled(&surface, &p, area, bounds, Some(&sel), tile, extent);
assert_eq!(expected.to_interleaved(bounds), actual.to_interleaved(bounds));
}
}
let cancelled = std::sync::atomic::AtomicBool::new(false);
let cancel = || cancelled.load(std::sync::atomic::Ordering::Relaxed);
let progress = |_| cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
let ctl = Interrupt::new(&cancel, &progress);
let input = surface.to_interleaved(bounds);
assert!(crate::apply_in_with(&surface, &p, area, bounds, Some(&sel), bounds, &ctl).is_none());
assert_eq!(surface.to_interleaved(bounds), input);
}
}
}
#[test]
fn preserve_details_resampling_keeps_the_same_sharpening() {
use crate::resample::{Resample, resize_surface};
let src = Rect::new(-3, 2, 19, 17);
let (sx, sy) = (2.0, 1.5);
let dst = crate::resample::scaled_rect(src, sx, sy);
for depth in SampleType::ALL {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
let s = source(src, PixelFormat::new(mode, depth, true));
let resized = resize_surface(&s, sx, sy, Resample::Lanczos);
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
let area = crate::output_area(&p, resized.content_bounds(), dst, None);
let expected = apply_reference(&resized, &p, area, dst, None, None, crate::auto_tile(&p, dst));
let actual = resize_surface(&s, sx, sy, Resample::PreserveDetails);
assert_eq!(actual.to_interleaved(area), expected.to_interleaved(area));
}
}
}
#[test]
fn flat_fields_and_sharp_edges_keep_exact_halos() {
let out = Rect::new(0, 0, 17, 9);
for alpha in [false, true] {
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha };
for radius in [0.1, 0.6, 3.25, 64.0] {
let rect = out.inflate((radius * 3.0_f32).ceil() as i32 + 1);
for edge in [false, true] {
let n = 3 + usize::from(alpha);
let data = (rect.y0..rect.y1)
.flat_map(|_| {
(rect.x0..rect.x1).flat_map(|x| {
(0..n).map(move |c| {
if alpha && c + 1 == n {
0.7
} else if edge && x >= 8 {
0.75
} else {
0.25
}
})
})
})
.collect();
let src = Image { rect, ch: n, data };
same_bits(&unsharp(&src, out, &ctx, 300.0, radius, 0.0), &reference::unsharp(&src, out, &ctx, 300.0, radius, 0.0));
}
}
}
}
#[test]
fn malformed_inputs_and_allocations_stop_gracefully() {
let rect = Rect::new(0, 0, 2, 2);
let src = Image { rect, ch: 1, data: vec![0.5; 4] };
let ctx = Ctx { bounds: rect, mode: ColorMode::Grayscale, alpha: false };
for (amount, radius, threshold) in [(f32::NAN, 1.0, 0.0), (100.0, f32::INFINITY, 0.0), (100.0, -1.0, 0.0), (100.0, f32::MAX, 0.0), (100.0, 1.0, f32::NAN)] {
assert!(exact::run(&src, rect, &ctx, amount, radius, threshold, &Interrupt::NONE).is_none());
let s = source(rect, PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
}
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
assert!(exact::run(&src, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
let s = source(Rect::new(0, 0, 2, 2), PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 1.0, threshold: 0.0 };
for tile in [i32::MIN, 0, i32::MAX] {
let r = s.content_bounds();
assert_eq!(crate::apply_tiled(&s, &p, r, r, None, tile, Some(r)), crate::apply_in(&s, &p, r, r, None, r));
}
// Allocation/geometry guard prevents a huge halo read before allocating it.
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 15_446.0, threshold: 0.0 };
assert!(crate::apply_in_with(&s, &p, s.content_bounds(), s.content_bounds(), None, s.content_bounds(), &Interrupt::NONE).is_none());
}
#[test]
fn maximum_supported_radius_matches_the_frozen_kernel() {
let out = Rect::new(0, 0, 1, 1);
let src = Image { rect: out, ch: 1, data: vec![0.75] };
let ctx = Ctx { bounds: out, mode: ColorMode::Grayscale, alpha: false };
same_bits(&unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0), &reference::unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0));
}
#[test]
fn cancellation_stops_inside_a_tile_before_publishing() {
let rect = Rect::new(0, 0, 16, 16);
let s = source(rect, PixelFormat::RGBA8);
let src = Image::read(&s, rect);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let checks = std::sync::atomic::AtomicUsize::new(0);
let cancel = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 10;
assert!(exact::run(&src, rect, &ctx, 150.0, 30.0, 8.0, &Interrupt::cancel_only(&cancel)).is_none());
assert_eq!(src, Image::read(&s, rect));
}
#[test]
#[ignore = "release full-operation baseline/candidate comparison; run alone"]
fn unsharp_performance() {
let number = |name: &str, default: i32| std::env::var(name).ok().and_then(|s| s.parse::<i32>().ok()).unwrap_or(default);
let (w, h, reps) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1500), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1000), number("PHOTOCRAFT_UNSHARP_REPS", 5));
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=15).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let channels = number("PHOTOCRAFT_UNSHARP_CHANNELS", 4);
let (mode, alpha) = match channels {
1 => (ColorMode::Grayscale, false),
2 => (ColorMode::Grayscale, true),
3 => (ColorMode::Rgb, false),
4 => (ColorMode::Rgb, true),
5 => (ColorMode::Cmyk, true),
_ => panic!("channels 1..5"),
};
let surface = source(area, PixelFormat::new(mode, depth, alpha));
let measure = std::env::var("PHOTOCRAFT_UNSHARP_MODE").unwrap_or_else(|_| "paired".into());
assert!(matches!(measure.as_str(), "paired" | "reference" | "candidate"));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into());
for case in cases.split(',') {
let values: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (values[0], values[1], values[2]);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
let tile = crate::auto_tile(&p, area);
let before = || apply_reference(&surface, &p, area, area, None, Some(area), tile);
// Isolate the streamed exact path from the new adaptive FFT dispatcher.
let after = || crate::apply_tiled_with_impl(&surface, &p, area, area, None, tile, Some(area), &Interrupt::NONE, false).unwrap();
if measure != "paired" {
let f = if measure == "reference" { &before as &dyn Fn() -> Surface } else { &after as &dyn Fn() -> Surface };
let mut times = Vec::new();
for rep in 0..reps + 2 {
let start = Instant::now();
let output = f();
let ms = start.elapsed().as_secs_f64() * 1000.0;
std::hint::black_box(output);
if rep >= 2 {
times.push(ms);
}
}
println!(
"{}",
serde_json::json!({"mode":measure,"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"runs_ms":times})
);
continue;
}
for _ in 0..2 {
std::hint::black_box(before());
std::hint::black_box(after());
}
let (mut a, mut b) = (Vec::new(), Vec::new());
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let start = Instant::now();
let s = f();
(s, start.elapsed().as_secs_f64() * 1000.0)
};
let ((old, old_ms), (new, new_ms)) = if rep % 2 == 0 {
(timed(&before), timed(&after))
} else {
let b = timed(&after);
(timed(&before), b)
};
assert_eq!(old.to_interleaved(area), new.to_interleaved(area));
a.push(old_ms);
b.push(new_ms);
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"before_ms":a,"after_ms":b,"before_median_ms":median(&a),"after_median_ms":median(&b),"speedup":median(&a)/median(&b),"max_error":0})
);
}
}
#[test]
#[ignore = "unchanged-filter stage profile; run alone in release"]
fn unsharp_baseline_profile() {
let out = Rect::new(0, 0, 256, 256);
for radius in [0.6, 3.0, 10.0, 30.0] {
let reach = (radius * 3.0_f32).ceil() as i32 + 1;
let s = source(out.inflate(reach), PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
let src = Image::read(&s, out.inflate(reach));
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let k = reference::gaussian_kernel(radius);
let mut times = [0.0; 4];
for _ in 0..20 {
let start = Instant::now();
std::hint::black_box(reference::gaussian_kernel(radius));
times[0] += start.elapsed().as_secs_f64();
let start = Instant::now();
let bl = reference::conv_sep(&src, out, &k, &k, true);
times[1] += start.elapsed().as_secs_f64();
let start = Instant::now();
let mut res = src.crop(out);
for (px, b) in res.as_chunks_mut::<4>().0.iter_mut().zip(bl.as_chunks::<4>().0) {
if px[3] <= 0.0 {
continue;
}
for c in 0..3 {
let d = px[c] - b[c];
if d.abs() + 1e-6 >= 8.0 / 255.0 {
px[c] += 1.5 * d;
}
}
}
std::hint::black_box(res);
times[2] += start.elapsed().as_secs_f64();
let start = Instant::now();
std::hint::black_box(reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
times[3] += start.elapsed().as_secs_f64();
}
println!(
"{}",
serde_json::json!({"baseline_tile_radius":radius,"kernel_ms":times[0]*50.0,"convolution_ms":times[1]*50.0,"crop_blend_ms":times[2]*50.0,"total_kernel_ms":times[3]*50.0})
);
}
}
+8 -2
View File
@@ -61,7 +61,14 @@ pub use shadows_highlights::shadows_highlights;
/// Returns the selection mask (what the dialog previews).
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
let mask = crate::selection::color_range(px, color, fuzziness);
for (q, &k) in px.iter_mut().zip(&mask) {
replace_color_masked(px, &mask, hue, saturation, lightness);
mask
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
for (q, &k) in px.iter_mut().zip(mask) {
if k <= 0.0 {
continue;
}
@@ -74,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
}
}
mask
}
/// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each.
+90
View File
@@ -0,0 +1,90 @@
//! Frozen scalar convolution from main 5dac5c6, used as an exact-output oracle.
use photocraft_algo::retouch::local_blur;
#[test]
fn matches_scalar_blur_bit_for_bit_across_layouts_edges_and_hdr() {
for (w, h) in [(1, 1), (3, 7), (37, 29)] {
for ch in 1..=5 {
for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
let data: Vec<f32> = (0..w * h * ch)
.map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
.collect();
for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
for sigma in [0.0, 0.25, 1.0, 3.0, 4.0, 12.0] {
let expected = scalar_local_blur(&data, w, h, ch, alpha, win, sigma);
let actual = local_blur(&data, w, h, ch, alpha, win, sigma);
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(a.to_bits(), b.to_bits(), "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} sigma={sigma} sample={i}");
}
}
}
}
}
}
}
#[test]
fn empty_channel_layout_remains_empty() {
assert!(local_blur(&[], 5, 5, 0, None, (0, 0, 5, 5), 4.0).is_empty());
}
#[allow(clippy::too_many_arguments)]
fn scalar_local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
}
out
}
+34
View File
@@ -363,6 +363,40 @@ mod tests {
}
}
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
let mut g = h.lock().unwrap();
let all = g.handle("engine.commands", json!({})).unwrap();
let filtered = g.handle("engine.commands", json!({"filter": "BLUR"})).unwrap();
let engine = g.handle("engine.execute", json!({"command": "command.list"})).unwrap();
for (name, reply) in [("engine.commands", &all), ("engine.commands {filter}", &filtered), ("command.list", &engine)] {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
assert_eq!(c["disabledReason"].is_null(), c["enabled"] == true, "disabledReason is null exactly when enabled: {c}");
assert!(c["menu"].as_array().unwrap().iter().all(Value::is_string), "{c}");
assert!(c["shortcut"].is_string() || c["shortcut"].is_null(), "{c}");
}
}
let n_all = all.as_array().unwrap().len();
assert_eq!(engine.as_array().unwrap().len(), n_all);
let filtered = filtered.as_array().unwrap();
assert!(filtered.len() < n_all);
assert!(filtered.iter().all(|c| format!("{} {}", c["id"], c["label"]).to_lowercase().contains("blur")));
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((&new["menu"], &new["shortcut"], &new["enabled"]), (&json!(["File"]), &json!("Cmd+N"), &json!(true)), "{new}");
assert_eq!((&new["disabledReason"], &new["needsDocument"]), (&Value::Null, &json!(false)), "{new}");
}
#[test]
fn lines_round_trip_and_errors() {
let h = session();
+2 -1
View File
@@ -796,7 +796,8 @@ impl PhotocraftMcp {
}
}
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \
#[tool(description = "List engine commands. Returns a JSON array (no wrapper object) of {id, label, menu: [path], \
shortcut: string|null, params: doc string, enabled: bool, disabledReason: string|null, needsDocument: bool}. \
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
document is open: create or open one first (doc_new, doc_open).")]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
+44 -2
View File
@@ -227,6 +227,27 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
// The display window is the full stage the renderer composed onto (a render pass's data
// window is often its crop). Open on the stage when it plausibly holds the data region:
// sane size for the limits, and the data region inside it. Otherwise fall back to the
// data window alone, as before.
let display = header.shared_attributes.display_window;
let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32);
let (stage, origin) = match display_size {
(sw, sh)
if sw <= 1 << 15
&& sh <= 1 << 15
&& limits.check_bytes(sw, sh, 4).is_ok()
&& dw.position.0 >= display.position.0
&& dw.position.1 >= display.position.1
&& dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32)
&& dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) =>
{
(Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1))
}
_ => (None, (0, 0)),
};
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
let multipart = meta.requirements.has_multiple_layers;
@@ -440,7 +461,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
}
}
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts: deep_counts })
}
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
@@ -565,7 +586,28 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
}
}
}
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
// Open on the full display-window stage when the file declares one: the renderer's data
// window is its crop, and the composition lives on the whole stage. The composed region
// pads to the stage at its offset, transparent elsewhere (limits validated the size).
let mut img = match deep.stage {
Some((sw, sh)) => {
let mut padded = vec![0f32; sw as usize * sh as usize * nc];
let row_len = deep.width as usize * nc;
let dx = deep.origin.0.max(0) as usize;
for row in 0..deep.height as usize {
let src = row * row_len;
let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx;
if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) {
d.copy_from_slice(r);
}
}
Image::from_f32(sw, sh, layout, &padded)?
}
None => Image::from_f32(deep.width, deep.height, layout, &out)?,
};
if let Some((sw, sh)) = deep.stage {
img.warnings.push(DecodeWarning::DisplayWindowStage { stage: (sw, sh), origin: deep.origin });
}
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tiff;
pub(crate) mod tiff_exif;
pub(crate) mod tiff_ifd;
pub(crate) mod via_image;
pub(crate) mod vp8;
+34 -1
View File
@@ -35,6 +35,7 @@ use tiff::encoder::colortype::{self, ColorType as TiffColorType};
use tiff::encoder::{Rational, TiffEncoder, TiffKind, TiffKindBig, TiffKindStandard, TiffValue};
use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, Type};
use super::tiff_exif::{self, ExifDirs};
use super::tiff_ifd::{self as ifd, File, Ifd};
use crate::Format;
use crate::error::CodecError;
@@ -297,6 +298,13 @@ fn metadata(f: &File<'_>, dir: &Ifd, icc: &mut Option<Vec<u8>>) -> Metadata {
if dir.at == f.first_ifd { photoshop_tags(f.b) } else { (f.tag_bytes(dir, TAG_PHOTOSHOP), f.tag_bytes(dir, TAG_IMAGE_SOURCE_DATA)) };
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
// EXIF: the Exif and GPS directories IFD0 points to, with IFD0's descriptive tags, as an
// EXIF block (the other pages are reduced copies or layers of the same image).
if dir.at == f.first_ifd
&& let Some(e) = tiff_exif::read(f.b).filter(ExifDirs::has_directories)
{
meta.exif = Some(tiff_exif::block(&e));
}
meta
}
@@ -1016,6 +1024,8 @@ custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5],
struct TagSet<'a> {
icc: Option<&'a [u8]>,
/// The EXIF block's directories and IFD0 tags, written as the TIFF's own.
exif: Option<ExifDirs>,
xmp: Option<&'a [u8]>,
dpi: Option<(f32, f32)>,
text: Vec<(Tag, &'a str)>,
@@ -1032,6 +1042,16 @@ where
[C::Inner]: TiffValue,
K: TiffKind,
{
// The Exif and GPS directories go before the image, outside its chain; IFD0 points to them.
let mut sub = |e: &ExifDirs, fields: &[tiff_exif::Field]| (!fields.is_empty()).then(|| tiff_exif::write_directory(enc, fields, e.le)).transpose();
let exif_at = match &tags.exif {
Some(e) => sub(e, &e.exif)?,
None => None,
};
let gps_at = match &tags.exif {
Some(e) => sub(e, &e.gps)?,
None => None,
};
let mut im = enc.new_image::<C>(w, h)?;
if let Some((x, y)) = tags.dpi {
im.resolution_unit(ResolutionUnit::Inch);
@@ -1052,6 +1072,17 @@ where
for (tag, s) in &tags.text {
d.write_tag(*tag, *s)?;
}
if let Some(e) = &tags.exif {
// The EXIF block's descriptive IFD0 tags, unless the text metadata already wrote them.
let ifd0: Vec<tiff_exif::Field> = e.ifd0.iter().filter(|f| !tags.text.iter().any(|(t, _)| t.to_u16() == f.tag)).cloned().collect();
tiff_exif::write_fields(d, &ifd0, e.le)?;
}
if let Some(at) = exif_at {
d.write_tag(Tag::Unknown(tiff_exif::TAG_EXIF_IFD), at)?;
}
if let Some(at) = gps_at {
d.write_tag(Tag::Unknown(tiff_exif::TAG_GPS_IFD), at)?;
}
if let Some(r) = tags.photoshop_resources {
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
}
@@ -1099,7 +1130,8 @@ const CLASSIC_LIMIT: u64 = u32::MAX as u64;
/// room for the directory and strip tables.
fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 {
let payloads = [tags.icc, tags.xmp, tags.photoshop_resources, tags.photoshop_layers].iter().flatten().map(|p| p.len() as u64).sum::<u64>();
let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::<u64>();
let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::<u64>()
+ tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::<u64>();
// Strip tables: one offset and one count per strip of about 1 MB.
let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16;
(img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
@@ -1132,6 +1164,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
let tags = TagSet {
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None },
xmp: xmp.as_deref().map(str::as_bytes),
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
text,
+195
View File
@@ -0,0 +1,195 @@
//! EXIF in a TIFF file: the Exif (34665) and GPS (34853) directories that IFD0 points to, plus
//! IFD0's descriptive tags (make, model, software, artist, …), the way cameras and Photoshop
//! write them.
//!
//! An EXIF block (`Metadata::exif`) is itself a small TIFF stream, so both directions go through
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
//! directories, writing one copies the block's directories into the file. Values are copied as
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
use tiff::tags::{Tag, Type};
use tiff::{Directory, TiffResult};
use super::tiff_ifd::{File, Ifd};
/// IFD0's pointer to the Exif directory.
pub(crate) const TAG_EXIF_IFD: u16 = 34665;
/// IFD0's pointer to the GPS directory.
pub(crate) const TAG_GPS_IFD: u16 = 34853;
/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only
/// the interoperability index, and a copied pointer would point nowhere).
const TAG_INTEROP_IFD: u16 = 40965;
/// IFD0 tags an EXIF block carries next to its directories: ImageDescription, Make, Model,
/// Software, DateTime, Artist, Copyright. Structure, resolution and orientation belong to the
/// image and are written from it.
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
/// At most this many entries are copied from one directory.
const MAX_FIELDS: usize = 1024;
/// One entry with its value bytes in the byte order of the stream it came from.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Field {
pub tag: u16,
pub ty: u16,
pub count: u32,
pub value: Vec<u8>,
}
/// The EXIF parts of a TIFF stream, in its byte order (`le`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ExifDirs {
pub le: bool,
pub ifd0: Vec<Field>,
pub exif: Vec<Field>,
pub gps: Vec<Field>,
}
impl ExifDirs {
/// Whether there is an Exif or GPS directory (IFD0's descriptive tags alone are not EXIF).
pub fn has_directories(&self) -> bool {
!self.exif.is_empty() || !self.gps.is_empty()
}
}
fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec<Field> {
ifd.entries
.iter()
.filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
.filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
.take(MAX_FIELDS)
.collect()
}
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
let at = f.tag_uint(ifd, tag)?;
(at != ifd.at).then(|| f.ifd(at)).flatten()
}
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
let f = File::parse(b)?;
if f.big {
return None;
}
let ifd0 = f.ifd(f.first_ifd)?;
let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
}
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
fn element(ty: u16) -> usize {
match ty {
3 | 8 => 2,
4 | 5 | 9 | 10 | 11 | 13 => 4,
12 | 16 | 17 | 18 => 8,
_ => 1,
}
}
/// `value` of type `ty` in byte order `to_le`, from `from_le`.
pub(crate) fn reorder(ty: u16, value: &[u8], from_le: bool, to_le: bool) -> Vec<u8> {
let n = element(ty);
if from_le == to_le || n == 1 {
return value.to_vec();
}
value.chunks(n).flat_map(|c| c.iter().rev().copied()).collect()
}
/// An EXIF block: a classic TIFF stream in `dirs.le` order whose IFD0 holds `dirs.ifd0` and
/// points to the Exif and GPS directories.
pub(crate) fn block(dirs: &ExifDirs) -> Vec<u8> {
let le = dirs.le;
let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() };
let u32b = |v: u32| if le { v.to_le_bytes() } else { v.to_be_bytes() };
let mut ifd0: Vec<Field> = dirs.ifd0.clone();
// Placeholders for the pointers; their offsets are known once the layout is.
let pointer = |tag: u16| Field { tag, ty: 4, count: 1, value: vec![0; 4] };
if !dirs.exif.is_empty() {
ifd0.push(pointer(TAG_EXIF_IFD));
}
if !dirs.gps.is_empty() {
ifd0.push(pointer(TAG_GPS_IFD));
}
// Layout: header, then each directory followed by its out-of-line values.
let size = |fs: &[Field]| 2 + 12 * fs.len() + 4 + fs.iter().filter(|f| f.value.len() > 4).map(|f| f.value.len().next_multiple_of(2)).sum::<usize>();
let at0 = 8;
let at_exif = at0 + size(&ifd0);
let at_gps = at_exif + if dirs.exif.is_empty() { 0 } else { size(&dirs.exif) };
for f in &mut ifd0 {
match f.tag {
TAG_EXIF_IFD => f.value = u32b(at_exif as u32).to_vec(),
TAG_GPS_IFD => f.value = u32b(at_gps as u32).to_vec(),
_ => {}
}
}
let mut out = Vec::new();
out.extend_from_slice(if le { b"II" } else { b"MM" });
out.extend_from_slice(&u16b(42));
out.extend_from_slice(&u32b(at0 as u32));
let write_dir = |out: &mut Vec<u8>, fs: &[Field]| {
let mut fs = fs.to_vec();
fs.sort_by_key(|f| f.tag);
let start = out.len();
let mut extra = start + 2 + 12 * fs.len() + 4;
let mut values = Vec::new();
out.extend_from_slice(&u16b(fs.len() as u16));
for f in &fs {
out.extend_from_slice(&u16b(f.tag));
out.extend_from_slice(&u16b(f.ty));
out.extend_from_slice(&u32b(f.count));
if f.value.len() <= 4 {
let mut inline = f.value.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&u32b(extra as u32));
values.extend_from_slice(&f.value);
if f.value.len() % 2 == 1 {
values.push(0);
}
extra += f.value.len().next_multiple_of(2);
}
}
out.extend_from_slice(&u32b(0));
out.extend_from_slice(&values);
};
write_dir(&mut out, &ifd0);
if !dirs.exif.is_empty() {
write_dir(&mut out, &dirs.exif);
}
if !dirs.gps.is_empty() {
write_dir(&mut out, &dirs.gps);
}
out
}
/// Writes `fields` (in byte order `from_le`) into a directory of `enc` that is not part of the
/// page chain, and returns its offset for the pointer tag.
pub(crate) fn write_directory<W: std::io::Write + std::io::Seek, K: TiffKind>(
enc: &mut TiffEncoder<W, K>,
fields: &[Field],
from_le: bool,
) -> TiffResult<K::OffsetType> {
let mut sub = enc.extra_directory()?;
write_fields(&mut sub, fields, from_le)?;
Ok(sub.finish_with_offsets()?.offset)
}
/// Adds `fields` (in byte order `from_le`) to directory `d`, in the file's (native) byte order.
pub(crate) fn write_fields<W: std::io::Write + std::io::Seek, K: TiffKind>(
d: &mut DirectoryEncoder<'_, W, K>,
fields: &[Field],
from_le: bool,
) -> TiffResult<()> {
let mut dir = Directory::empty();
for f in fields {
let Some(ty) = Type::from_u16(f.ty) else { continue };
let entry = d.write_entry_bytes(ty, &reorder(f.ty, &f.value, from_le, cfg!(target_endian = "little")))?;
dir.extend([(Tag::from_u16_exhaustive(f.tag), entry)]);
}
d.extend_from(&dir);
Ok(())
}
+1 -1
View File
@@ -233,7 +233,7 @@ pub fn caps(format: Format) -> FormatCaps {
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, xmp: true, dpi: true, text: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
+12
View File
@@ -150,6 +150,8 @@ pub enum DecodeWarning {
MorePages { total: Option<u32> },
/// Only one part of a multi-part file was decoded; `total` counts every part when known.
MoreParts { total: Option<u32> },
/// The image opened on the full `display_window` stage; the data region sits at `origin`.
DisplayWindowStage { stage: (u32, u32), origin: (i32, i32) },
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
/// (a baseline JPEG's missing rows come out grey).
Truncated { format: Format },
@@ -193,6 +195,9 @@ impl fmt::Display for DecodeWarning {
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
DecodeWarning::MoreParts { total: Some(n) } => write!(f, "only one of {n} EXR parts was imported"),
DecodeWarning::MoreParts { total: None } => write!(f, "only one EXR part of the file was imported"),
DecodeWarning::DisplayWindowStage { stage, origin } => {
write!(f, "opened on the full display window stage ({}x{}); the data sits at ({}, {})", stage.0, stage.1, origin.0, origin.1)
}
DecodeWarning::Truncated { format } => {
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
}
@@ -232,6 +237,13 @@ pub struct DeepChannel {
pub struct DeepImage {
pub width: u32,
pub height: u32,
/// The full stage the image opens on: the EXR `display_window` size, when the file declares
/// one the data region fits into and the limits allow. `None` when absent, absurd or not
/// containing the data region — then the image opens on the data window alone, as before.
pub stage: Option<(u32, u32)>,
/// Where this image's (0, 0) sits on the stage: `data_window.position - display_window.position`.
/// `(0, 0)` when [`Self::stage`] is `None`.
pub origin: (i32, i32),
/// The sample channels in file order.
pub channels: Vec<DeepChannel>,
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
+131
View File
@@ -254,6 +254,9 @@ fn gen_deep(
out
}
/// RGBA float channels in chlist (alphabetical) order, no Z (everything composites).
const RGBA_NO_Z: [Chan; 4] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }];
/// RGBA(Z) float channels in chlist (alphabetical) order.
const RGBAZ: [Chan; 5] =
[Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }];
@@ -709,3 +712,131 @@ fn real_deep_files_decode() {
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DeepFlattened { .. })), "{name}");
}
}
/// `gen_deep` for a cropped render: the data window sits at `(dwx, dwy)` inside a larger
/// display window of `sw × sh` (the stage the renderer composed onto). Scanline chunk Y
/// coordinates are absolute display rows, so they carry the data-window offset.
#[allow(clippy::too_many_arguments)]
fn gen_deep_cropped(
w: usize,
h: usize,
dwx: i32,
dwy: i32,
sw: usize,
sh: usize,
channels: &[Chan],
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
out.extend_from_slice(&(2u32 | 0x800).to_le_bytes());
let mut chlist = Vec::new();
for c in channels {
chlist.extend_from_slice(c.name.as_bytes());
chlist.push(0);
chlist.extend_from_slice(&c.ty.to_le_bytes());
chlist.push(0);
chlist.extend_from_slice(&[0u8; 3]);
chlist.extend_from_slice(&1i32.to_le_bytes());
chlist.extend_from_slice(&1i32.to_le_bytes());
}
chlist.push(0);
attr(&mut out, "channels", "chlist", &chlist);
attr(&mut out, "compression", "compression", &[0u8]);
// box2i is inclusive: the windows end at position + size - 1.
let dw: Vec<u8> = [dwx, dwy, dwx + w as i32 - 1, dwy + h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
attr(&mut out, "dataWindow", "box2i", &dw);
let disp: Vec<u8> = [0i32, 0, sw as i32 - 1, sh as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect();
attr(&mut out, "displayWindow", "box2i", &disp);
attr(&mut out, "lineOrder", "lineOrder", &[0]);
attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes());
attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat());
attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes());
attr(&mut out, "type", "string", b"deepscanline");
attr(&mut out, "version", "int", &1i32.to_le_bytes());
out.push(0);
// One scanline block per data row; the pixel indexing stays data-relative.
let mut chunks: Vec<Vec<u8>> = Vec::new();
for y in 0..h {
let pixels: Vec<usize> = (y * w..y * w + w).collect();
let mut c = Vec::new();
c.extend_from_slice(&(dwy + y as i32).to_le_bytes());
let mut table = Vec::with_capacity(pixels.len() * 4);
let mut acc = 0u32;
for &p in &pixels {
acc += counts[p];
table.extend_from_slice(&acc.to_le_bytes());
}
let mut data = Vec::new();
for (ci, _) in channels.iter().enumerate() {
for &p in &pixels {
for v in sample(p, ci) {
data.extend_from_slice(&v.to_le_bytes());
}
}
}
c.extend_from_slice(&(table.len() as u64).to_le_bytes());
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&table);
c.extend_from_slice(&data);
chunks.push(c);
}
let mut pos = out.len() + chunks.len() * 8;
let mut table = Vec::new();
for c in &chunks {
table.extend_from_slice(&(pos as u64).to_le_bytes());
pos += c.len();
}
out.extend_from_slice(&table);
for c in chunks {
out.extend_from_slice(&c);
}
out
}
/// A cropped render opens on the full display-window stage: MY27-style renders carry a
/// `display_window` of the full frame (e.g. 4000×3000) while the deep data window is the
/// render pass's crop inside it. The image must open at stage size with the data at its
/// crop offset, transparent elsewhere — not cropped to the data window.
#[test]
fn cropped_deep_data_opens_on_the_display_stage() {
let (w, h, dwx, dwy, sw, sh) = (24usize, 16usize, 8i32, 6i32, 48usize, 32usize);
let counts = vec![1u32; w * h];
let sample = |p: usize, ci: usize| -> Vec<f32> {
// chlist order (RGBA_NO_Z): A, B, G, R.
let x = p % w;
let y = p / w;
match ci {
0 => vec![1.0], // A
1 => vec![0.5], // B
2 => vec![y as f32 / h as f32], // G
_ => vec![x as f32 / w as f32], // R
}
};
let bytes = gen_deep_cropped(w, h, dwx, dwy, sw, sh, &RGBA_NO_Z, &counts, &sample);
let img = decode(&bytes).expect("cropped deep EXR decodes");
assert_eq!((img.width(), img.height()), (sw as u32, sh as u32), "opens on the display window stage");
assert!(img.warnings.iter().any(|w| matches!(w, photocraft_codecs::DecodeWarning::DisplayWindowStage { origin, .. } if *origin == (dwx, dwy))));
// The data sits at its crop offset: the first data pixel's green channel carries its
// data-row fraction, everything outside the data region stays transparent black.
let s = img.to_f32_samples().expect("f32");
let nc = s.len() / (sw * sh);
let px = |x: usize, y: usize| -> Vec<f32> { s[y * sw * nc + x * nc..y * sw * nc + x * nc + nc].to_vec() };
let first = px(dwx as usize, dwy as usize);
assert!(
(first[0] - 0.0).abs() < 1e-5 && (first[1] - 0.0).abs() < 1e-5 && (first[2] - 0.5).abs() < 1e-5 && (first[3] - 1.0).abs() < 1e-5,
"first data pixel at the offset: {first:?}"
);
let last = px(dwx as usize + w - 1, dwy as usize + h - 1);
assert!(
(last[0] - (w - 1) as f32 / w as f32).abs() < 1e-5 && (last[1] - (h - 1) as f32 / h as f32).abs() < 1e-5,
"last data pixel keeps its coordinates: {last:?}"
);
let outside = px(0, 0);
assert_eq!(outside, vec![0.0, 0.0, 0.0, 0.0], "outside the data region: transparent");
}
+5 -1
View File
@@ -34,7 +34,11 @@ fn check_format(format: Format) {
} else {
assert!(warnings.contains(&FidelityWarning::IccDropped), "{format:?}");
}
if c.exif {
if c.exif && format == Format::Tiff {
// TIFF writes the block's directories as its own and rebuilds a block from them on
// reading (tests/tiff_exif.rs); IFD0's orientation here is the image's.
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
} else if c.exif {
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
} else {
assert!(warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
+168
View File
@@ -0,0 +1,168 @@
//! EXIF in TIFF: an EXIF block's Exif and GPS directories and IFD0's descriptive tags are
//! written as the TIFF's own directories (IFD0 pointing to them, as cameras and Photoshop write
//! them) and read back into a block.
use photocraft_codecs::*;
/// A classic TIFF stream builder for EXIF blocks: directories of (tag, type, count, value bytes).
struct Block {
le: bool,
}
type Entry = (u16, u16, u32, Vec<u8>);
impl Block {
fn u16(&self, v: u16) -> [u8; 2] {
if self.le { v.to_le_bytes() } else { v.to_be_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.le { v.to_le_bytes() } else { v.to_be_bytes() }
}
fn rational(&self, n: u32, d: u32) -> Vec<u8> {
[self.u32(n), self.u32(d)].concat()
}
/// IFD0 (with pointers to `exif` and `gps` appended) followed by the two directories.
fn build(&self, mut ifd0: Vec<Entry>, exif: Vec<Entry>, gps: Vec<Entry>) -> Vec<u8> {
let size = |d: &[Entry]| 2 + 12 * d.len() + 4 + d.iter().filter(|e| e.3.len() > 4).map(|e| e.3.len().next_multiple_of(2)).sum::<usize>();
ifd0.push((34665, 4, 1, vec![0; 4]));
ifd0.push((34853, 4, 1, vec![0; 4]));
let at_exif = 8 + size(&ifd0);
let at_gps = at_exif + size(&exif);
let n = ifd0.len();
ifd0[n - 2].3 = self.u32(at_exif as u32).to_vec();
ifd0[n - 1].3 = self.u32(at_gps as u32).to_vec();
let mut out = if self.le { b"II".to_vec() } else { b"MM".to_vec() };
out.extend(self.u16(42));
out.extend(self.u32(8));
for d in [ifd0, exif, gps] {
let start = out.len();
let mut extra = start + 2 + 12 * d.len() + 4;
let mut values = Vec::new();
out.extend(self.u16(d.len() as u16));
for (tag, ty, count, v) in &d {
out.extend(self.u16(*tag));
out.extend(self.u16(*ty));
out.extend(self.u32(*count));
if v.len() <= 4 {
let mut inline = v.clone();
inline.resize(4, 0);
out.extend(inline);
} else {
out.extend(self.u32(extra as u32));
values.extend(v);
if v.len() % 2 == 1 {
values.push(0);
}
extra += v.len().next_multiple_of(2);
}
}
out.extend(self.u32(0));
out.extend(values);
}
out
}
}
/// The entries (tag, type, count, value bytes in the block's order) of IFD0 and of the
/// directories its tags `34665` and `34853` point to.
fn dirs(b: &[u8]) -> (bool, Vec<Entry>, Vec<Entry>, Vec<Entry>) {
let le = &b[..2] == b"II";
let u16 = |at: usize| if le { u16::from_le_bytes([b[at], b[at + 1]]) } else { u16::from_be_bytes([b[at], b[at + 1]]) };
let u32 = |at: usize| {
let s = [b[at], b[at + 1], b[at + 2], b[at + 3]];
if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) }
};
let size = |ty: u16| match ty {
3 | 8 => 2,
4 | 9 | 11 | 13 => 4,
5 | 10 | 12 => 8,
_ => 1,
};
let read = |at: usize| -> Vec<Entry> {
(0..usize::from(u16(at)))
.map(|i| {
let e = at + 2 + 12 * i;
let (tag, ty, count) = (u16(e), u16(e + 2), u32(e + 4));
let len = count as usize * size(ty);
let at = if len <= 4 { e + 8 } else { u32(e + 8) as usize };
(tag, ty, count, b[at..at + len].to_vec())
})
.collect()
};
let ifd0 = read(u32(4) as usize);
let sub = |tag: u16| ifd0.iter().find(|e| e.0 == tag).map(|e| read(u32_of(&e.3, le) as usize)).unwrap_or_default();
let (exif, gps) = (sub(34665), sub(34853));
(le, ifd0.into_iter().filter(|e| !matches!(e.0, 34665 | 34853)).collect(), exif, gps)
}
fn u32_of(v: &[u8], le: bool) -> u32 {
let s = [v[0], v[1], v[2], v[3]];
if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) }
}
/// The entries of `d` with their values in little-endian order, sorted by tag.
fn normalized(d: &[Entry], le: bool) -> Vec<(u16, u16, u32, Vec<u8>)> {
let swap = |ty: u16, v: &[u8]| -> Vec<u8> {
let n = match ty {
3 | 8 => 2,
4 | 5 | 9 | 10 | 11 | 13 => 4,
12 => 8,
_ => 1,
};
if le || n == 1 { v.to_vec() } else { v.chunks(n).flat_map(|c| c.iter().rev().copied()).collect() }
};
let mut v: Vec<_> = d.iter().map(|e| (e.0, e.1, e.2, swap(e.1, &e.3))).collect();
v.sort_by_key(|e| e.0);
v
}
fn camera_block(le: bool) -> Vec<u8> {
let b = Block { le };
let ascii = |s: &str| [s.as_bytes(), b"\0"].concat();
b.build(
vec![(271, 2, 4, ascii("Cam")), (272, 2, 9, ascii("Model X1")), (315, 2, 16, ascii("A. Photographer")), (274, 3, 1, b.u16(1).to_vec())],
vec![
(33434, 5, 1, b.rational(1, 125)),
(34855, 3, 1, b.u16(200).to_vec()),
(36867, 2, 20, ascii("2026:10:13 09:30:00")),
(37386, 5, 1, b.rational(50, 1)),
(37500, 7, 6, b"maker!".to_vec()),
],
vec![(1, 2, 2, ascii("N")), (2, 5, 3, [b.rational(51, 1), b.rational(30, 1), b.rational(0, 1)].concat())],
)
}
#[test]
fn exif_round_trips_through_a_tiff_in_either_byte_order() {
for le in [true, false] {
let block = camera_block(le);
let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap();
img.meta.exif = Some(block.clone());
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
assert!(fidelity_warnings(&img, Format::Tiff).iter().all(|w| *w != FidelityWarning::ExifDropped));
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
let (ble, b0, bexif, bgps) = dirs(&back);
let (ole, o0, oexif, ogps) = dirs(&block);
assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "Exif directory (le {le})");
assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole), "GPS directory (le {le})");
// IFD0's descriptive tags come along; its orientation is the image's own.
let descriptive: Vec<_> = o0.into_iter().filter(|e| e.0 != 274).collect();
assert_eq!(normalized(&b0, ble), normalized(&descriptive, ole), "IFD0 (le {le})");
}
}
#[test]
fn a_tiff_without_exif_directories_reads_no_exif() {
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
img.meta.text = vec![("Artist".into(), "A. Photographer".into())];
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
}
#[test]
fn no_exif_is_written_without_embed_metadata() {
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
img.meta.exif = Some(camera_block(true));
let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap();
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
}
+98 -22
View File
@@ -15,10 +15,10 @@
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
//! lower ones. Drop shadows are knocked out only through see-through fill.
//!
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
//! are two box blurs (a tent).
//! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows /
//! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons,
//! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the
//! cheaper chamfer. Soft falloffs are two box blurs (a tent).
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
@@ -394,12 +394,30 @@ fn dist_outside(s: &Map) -> Vec<f32> {
/// Distance metric for effect distance fields.
#[derive(Clone, Copy, PartialEq)]
enum Metric {
/// Exact Euclidean (precise glows, bevels).
/// Exact Euclidean (effect edges).
Euclidean,
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
/// 5 × 5 chamfer (inside strokes, spread / choke; see [`chamfer_nearest`]).
Chamfer,
}
/// Outside width from which a stroke's outer edge uses the exact Euclidean transform instead of
/// the fitted 5 × 5 chamfer.
///
/// The chamfer's level sets are octagons: measured against a point source, its distance exceeds
/// the Euclidean radius by up to `0.027 · r` px — 0.48 px at r = 18 (the widest outside stroke any
/// Photoshop oracle matches with the chamfer) and 1.6 px at r = 60, where a wide stroke's boundary
/// visibly facets (#3646). Half a pixel of deviation is reached at r ≈ 19, so strokes narrower
/// than 20 px keep the chamfer, whose sub-pixel edge seeding Photoshop matches at small offsets,
/// and wider ones stay round.
pub const STROKE_CHAMFER_MAX: f32 = 20.0;
/// Whether a stroke whose outside edge is `out_w` px wide uses the exact Euclidean transform (round)
/// rather than the 5 × 5 chamfer's octagon ([`STROKE_CHAMFER_MAX`]). The CPU compositor and the GPU
/// compositor's `program_with` both call this, so a stroke picks the same field in each.
pub fn stroke_outside_exact(out_w: f32) -> bool {
out_w >= STROKE_CHAMFER_MAX
}
fn dist_outside_by(s: &Map, m: Metric) -> Vec<f32> {
if m == Metric::Chamfer {
// Seeds start at their sub-pixel edge offset (1 − coverage).
@@ -1657,9 +1675,14 @@ pub struct FxMaps {
per: Vec<Vec<Map>>,
bevel_paint: Vec<BevelPaint>,
din: Option<Vec<f32>>,
/// Stroke outside distance below [`STROKE_CHAMFER_MAX`]: the fitted 5 × 5 chamfer.
dout: Option<Vec<f32>>,
/// Shape layers: distance outside the vector outline (from local coverage).
/// Stroke outside distance at or above [`STROKE_CHAMFER_MAX`]: the exact Euclidean transform.
dout_exact: Option<Vec<f32>>,
/// Shape layers: chamfer distance outside the vector outline (from local coverage).
vdout: Option<Vec<f32>>,
/// Shape layers: exact Euclidean distance outside the vector outline (from local coverage).
vdout_exact: Option<Vec<f32>>,
/// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).
outline: bool,
/// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].
@@ -1678,7 +1701,14 @@ impl FxMaps {
pub fn bytes(&self) -> usize {
let n = self.shape.v.len();
let maps: usize = self.per.iter().map(Vec::len).sum();
(1 + maps + usize::from(self.din.is_some()) + usize::from(self.dout.is_some()) + usize::from(self.vdout.is_some())) * n * 4
(1 + maps
+ usize::from(self.din.is_some())
+ usize::from(self.dout.is_some())
+ usize::from(self.dout_exact.is_some())
+ usize::from(self.vdout.is_some())
+ usize::from(self.vdout_exact.is_some()))
* n
* 4
}
fn crop_vec(&self, v: &[f32], to: Rect, fill: f32) -> Vec<f32> {
@@ -1765,10 +1795,34 @@ pub(crate) fn build_maps_prepared(
.collect();
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) };
// Without an outline (unfilled shapes), estimate the vector outline from local coverage.
// A stroke's outer edge keeps the 5 × 5 chamfer while its outside width is under
// `STROKE_CHAMFER_MAX` — Photoshop matches the chamfer's sub-pixel edge seeding at small
// offsets — and uses the exact Euclidean transform at or above it, where the chamfer's octagon
// facets a wide stroke (#3646). Inside stroke distances always keep the chamfer. Each outer
// field is built only when a stroke of that width is present; the compositor picks per stroke,
// so one layer may hold both a narrow and a wide stroke.
let out_widths: Vec<f32> =
items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(stroke_widths(st).1) } else { None }).filter(|w| *w > 0.0).collect();
let chamfer_out = out_widths.iter().any(|&w| !stroke_outside_exact(w));
let exact_out = out_widths.iter().any(|&w| stroke_outside_exact(w));
let (din, dout, dout_exact) = if has_stroke {
(
Some(dist_inside_by(&shape, Metric::Chamfer)),
chamfer_out.then(|| dist_outside_by(&shape, Metric::Chamfer)),
exact_out.then(|| dist_outside_by(&shape, Metric::Euclidean)),
)
} else {
(None, None, None)
};
// Without an outline (unfilled shapes), estimate the vector outline from local coverage; it
// picks the chamfer or the exact transform the same way.
let outline = crate::effect_outline(layer).is_some();
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
let (vdout, vdout_exact) = if has_stroke && vector_shape && !outline {
let lc = local_coverage(&shape);
(chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean)))
} else {
(None, None)
};
let mut frames: Vec<(u32, Rect)> = Vec::new();
for e in &items {
if let Effect::Stroke(st) = e
@@ -1780,7 +1834,7 @@ pub(crate) fn build_maps_prepared(
}
}
}
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames }
FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, outline, frames }
}
/// Far outside any shape (distance fill for cropped distance fields).
@@ -1963,10 +2017,13 @@ pub(crate) fn composite_with_effects_prepared(
// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
// are slid beneath it, so the first listed (top) instance is processed first.
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
let (din, dout) = match (&maps.din, &maps.dout) {
(Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)),
_ => (Vec::new(), Vec::new()),
};
let ready = !strokes.is_empty();
let din = if ready { maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default() } else { Vec::new() };
// A stroke's outer edge picks the chamfer or the exact field by its width
// ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist.
let crop_out = |v: &Option<Vec<f32>>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR));
let (dout, dout_exact, vdout, vdout_exact) =
if ready { (crop_out(&maps.dout), crop_out(&maps.dout_exact), crop_out(&maps.vdout), crop_out(&maps.vdout_exact)) } else { (None, None, None, None) };
let widths = stroke_widths;
// A gradient stroke aligned with the layer spans the stroke's own extent.
let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb);
@@ -1994,7 +2051,9 @@ pub(crate) fn composite_with_effects_prepared(
for (p, a) in lay.px.iter_mut().zip(&shape.v) {
p[3] *= a.min(1.0);
}
let vdout = maps.vdout.as_ref().filter(|_| vector_shape && !strokes.is_empty()).map(|v| maps.crop_vec(v, big, FAR));
// Shape layers without a filled outline stroke their vector outline, estimated from local
// coverage; every other layer strokes its shape. The width then picks the field.
let vector_outline = vector_shape && !maps.outline;
// Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A
// higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider
// Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its
@@ -2012,7 +2071,14 @@ pub(crate) fn composite_with_effects_prepared(
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
// filled shape's outline it covers the part of each edge pixel outside the path.
let d = vdout.as_ref().unwrap_or(&dout);
let exact = stroke_outside_exact(out_w);
let d: &[f32] = if vector_outline {
(if exact { vdout_exact.as_deref() } else { vdout.as_deref() }).unwrap_or_default()
} else if exact {
dout_exact.as_deref().unwrap_or_default()
} else {
dout.as_deref().unwrap_or_default()
};
let mut share = vec![0.0f32; w * h];
for (i, sh) in share.iter_mut().enumerate() {
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
@@ -2176,15 +2242,23 @@ pub enum FieldKind {
BevelOutside,
/// `bevel_dist_inside` of the shape: the inside distance of chiselled bevels.
BevelInside,
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
/// softer outer glows ([`dilate`]).
/// Chamfer distance outside the shape: a narrow raster stroke's outer edge (see
/// [`stroke_outside_exact`]), and the spread of drop shadows and softer outer glows
/// ([`dilate`]); their metric is fitted to Photoshop (#224) and keeps the sub-pixel seeding.
StrokeOutside,
/// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows.
ChokeInside,
/// Stroke distance inside the shape.
StrokeInside,
/// Stroke distance outside a shape layer's outline (from its local coverage).
/// Exact Euclidean distance outside the shape: a wide raster stroke's outer edge (see
/// [`stroke_outside_exact`]). The chamfer's level sets are polygons (an ~8-fold octagon), so a
/// wide stroke's outer boundary facets; the shadow / glow spread above keeps the chamfer (#3646).
StrokeOutsideExact,
/// Stroke distance outside a shape layer's outline (from its local coverage), exact Euclidean.
StrokeOutsideVector,
/// Chamfer distance outside a shape layer's outline (from its local coverage): a narrow vector
/// stroke's outer edge, where the chamfer matches Photoshop (see [`STROKE_CHAMFER_MAX`]).
StrokeOutsideVectorChamfer,
}
/// The `kind` distance field of the shape `alpha` (row-major `w × h`). Values past `reach` pixels
@@ -2199,7 +2273,9 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize, reac
FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer),
FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer),
FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer),
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
FieldKind::StrokeOutsideExact => dist_outside_by(&s, Metric::Euclidean),
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Euclidean),
FieldKind::StrokeOutsideVectorChamfer => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
}
}
+15 -3
View File
@@ -58,11 +58,23 @@ impl Buffer {
}
pub fn to_rgba8(&self) -> Rgba8Image {
let mut img = Rgba8Image::new(self.rect.width(), self.rect.height());
for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&self.px) {
for (dst, v) in o.iter_mut().zip(p) {
*dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
let quantize = |out: &mut [[u8; 4]], src: &[[f32; 4]]| {
for (o, p) in out.iter_mut().zip(src) {
for (dst, v) in o.iter_mut().zip(p) {
*dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
};
let out = img.pixels.as_chunks_mut::<4>().0;
// In parallel off wasm: a 24 MP composite took 120 ms on one core (the CPU canvas,
// previews, thumbnails and 8-bit exports all come through here).
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(16 * 1024).zip(self.px.par_chunks(16 * 1024)).for_each(|(o, p)| quantize(o, p));
}
#[cfg(target_arch = "wasm32")]
quantize(out, &self.px);
img
}
/// Flatten over an opaque background colour.
+48 -4
View File
@@ -1315,17 +1315,61 @@ fn shapeless_effect_maps_cover_only_the_band_being_rendered() {
// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ----------
#[test]
fn stroke_corners_follow_the_5x5_chamfer_metric() {
// Square 10..30, outside stroke 3: pixel (9, 7) is offset (1, 3) from the corner pixel.
// Photoshop measures √5 + 1 = 3.236 there (exact distance 3.162): coverage 3 + 1 − 3.236.
fn stroke_edges_use_the_chamfer_when_narrow_and_the_exact_distance_when_wide() {
// #3646: a narrow stroke's outer edge keeps the 5 × 5 chamfer, whose sub-pixel seeding
// Photoshop matches at small offsets (see `stroke_outside_exact`); only a wide one — where the
// chamfer's octagon facets (see `a_wide_outside_stroke_is_round_not_a_polygon`) — uses the
// exact Euclidean transform.
// Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel. Photoshop
// measures √5 + 1 = 3.236 there, so coverage is 3 + 1 − 3.236 = 0.764 (the exact distance is
// 3.162, coverage 0.838).
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage
assert!((a - 0.764).abs() < 0.01, "{a}");
// (2, 2) stays Euclidean (two diagonal steps).
// (2, 2) is the same in both metrics (two diagonal steps).
let a = 1.0 - px(&d, 8, 8)[0];
assert!((a - 1.0).abs() < 0.01, "{a}");
}
#[test]
fn the_stroke_metric_switches_at_the_fitted_threshold() {
// The widest outside stroke any Photoshop oracle matches with the chamfer is 18 px
// (ag-psd read-write/strokes): its octagon deviates 0.48 px from a circle there; 20 px keeps
// it. Wider strokes (the 60 px #3646 case) are round.
assert!(!crate::effects::stroke_outside_exact(18.0));
assert!(crate::effects::stroke_outside_exact(crate::effects::STROKE_CHAMFER_MAX));
assert!(crate::effects::stroke_outside_exact(60.0));
}
#[test]
fn a_wide_outside_stroke_is_round_not_a_polygon() {
// A point source dilated by 60 px must stay a circle. The 5 × 5 chamfer's octagonal level set
// made the boundary swing ~2.5 px at this radius (~10 px at 245 px) — the "low-poly" #3646.
let mut d = doc_white(170, 170);
let mut l = solid_layer("dot", Rect::new(85, 85, 86, 86), [1.0, 0.0, 0.0, 1.0]);
l.effects.items = vec![stroke(60.0, StrokePosition::Outside)];
d.layers.push(l);
let buf = render(&d, Rect::new(0, 0, 170, 170));
let at = |x: i32, y: i32| buf.px[(y as usize) * 170 + x as usize];
let (mut lo, mut hi) = (f64::MAX, 0.0f64);
let stroke_at = |x: i32, y: i32| x >= 0 && y >= 0 && x < 170 && y < 170 && at(x, y)[2] > 0.5 && at(x, y)[0] < 0.5;
for k in 0..72 {
let th = (k as f64) * std::f64::consts::TAU / 72.0;
let (dx, dy) = (th.cos(), th.sin());
let mut r = 0.0f64;
let mut t = 0.0f64;
while t < 84.0 {
if stroke_at((85.0 + dx * t).round() as i32, (85.0 + dy * t).round() as i32) {
r = t;
}
t += 0.25;
}
lo = lo.min(r);
hi = hi.max(r);
}
assert!(hi - lo <= 2.0, "the 60 px stroke boundary swings {lo}..{hi} px — a polygon, not a circle (#3646)");
}
#[test]
fn inside_stroke_respects_partial_edge_coverage() {
// A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then
+47
View File
@@ -99,6 +99,27 @@ pub struct Locks {
}
impl Locks {
/// Set the lock a command names (`transparency`, `pixels`, `position`, `artboard`, `all`);
/// false for an unknown name. Lock Image Pixels includes Lock Transparent Pixels, so, as in
/// Photoshop, locking the pixels clears the transparency lock, and that one stays off while the
/// pixels are locked (#4892).
pub fn set(&mut self, key: &str, on: bool) -> bool {
match key {
"transparency" => self.transparency = on && !self.pixels,
"pixels" => {
self.pixels = on;
if on {
self.transparency = false;
}
}
"position" => self.position = on,
"artboard" => self.artboard = on,
"all" => self.all = on,
_ => return false,
}
true
}
/// Whether any lock is set: Photoshop then shows the lock icon on the layer's row.
pub fn any(self) -> bool {
self.transparency || self.pixels || self.position || self.artboard || self.all
@@ -844,6 +865,10 @@ pub struct Document {
pub layers: Vec<Layer>,
pub channels: Vec<AlphaChannel>,
pub guides: Guides,
/// The rulers' zero point in document pixels (`[x, y]`), dragged from the ruler corner. The
/// rulers, the Info panel and other position readouts measure from it; `[0, 0]` (the
/// image's top-left) is the default.
pub ruler_origin: [f64; 2],
/// Active selection as a grayscale coverage surface (None = no selection).
pub selection: Option<Surface>,
pub metadata: Metadata,
@@ -931,6 +956,7 @@ impl Document {
layers: Vec::new(),
channels: Vec::new(),
guides: Guides::default(),
ruler_origin: [0.0, 0.0],
selection: None,
metadata: Metadata::default(),
global_light: GlobalLight::default(),
@@ -1148,6 +1174,16 @@ impl Document {
id
}
/// Insert `layer` directly below the layer `below` (or at the bottom of the root if None).
pub fn insert_below(&mut self, below: Option<LayerId>, layer: Layer) -> LayerId {
let id = layer.id;
match below.and_then(|b| self.path_of(b)).and_then(|path| self.siblings_mut(&path)) {
Some((sib, idx)) => sib.insert(idx, layer),
None => self.layers.insert(0, layer),
}
id
}
pub fn remove(&mut self, id: LayerId) -> Option<Layer> {
let path = self.path_of(id)?;
let (sib, idx) = self.siblings_mut(&path)?;
@@ -1345,6 +1381,17 @@ mod tests {
assert!(d.layer(a).is_none());
}
#[test]
fn insert_below_lands_under_the_reference() {
let mut d = doc();
let bg = d.layers[0].id;
let a = d.insert_above(Some(bg), Layer::raster("A", d.pixel_format()));
let b = d.insert_below(Some(a), Layer::raster("B", d.pixel_format()));
let names: Vec<_> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Background", "B", "A"]);
assert_eq!(d.layers[1].id, b);
}
#[test]
fn groups_walk_and_paths() {
let mut d = doc();
+62
View File
@@ -0,0 +1,62 @@
//! Reproducible soft Blur strokes on a 24 MP striped document. Run in release mode.
//! Setup, Undo and the output fingerprint are excluded from the timing.
use std::time::Instant;
use photocraft_engine::{EngineError, Session};
use photocraft_geom::Rect;
use serde_json::json;
fn run() -> Result<(), Box<dyn std::error::Error>> {
for depth in [8, 16, 32] {
let mut session = Session::new();
session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?;
session.execute("layer.new.layer", json!({}))?;
session.edit("stripes", |doc, active| {
let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
for x in (0..6000).step_by(64) {
let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };
surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color);
}
Ok(())
})?;
for size in [100, 500, 1000] {
let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25});
let mut times = Vec::new();
let mut fingerprint = 0u64;
for sample in 0..6 {
let start = Instant::now();
session.execute("paint.blur", params.clone())?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
if sample > 0 {
times.push(ms);
}
if sample == 5 {
let st = session.active().ok_or("no document")?;
let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?;
for y in (1500..2500).step_by(17) {
for x in (2000..4000).step_by(13) {
for channel in surface.rgba(x, y) {
fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits());
}
}
}
}
session.execute("edit.undo", json!({}))?;
}
times.sort_by(f64::total_cmp);
println!(
"depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}",
times[2], times[0], times[4]
);
}
}
Ok(())
}
fn main() {
if let Err(error) = run() {
eprintln!("{error}");
std::process::exit(1);
}
}
+43 -3
View File
@@ -334,14 +334,54 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
}
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.adjustments.replaceColor";
let c = crate::commands::color_param(p, "color", s.tools.foreground);
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0));
rgba_edit(s, "Replace Color", p, |px, _| {
tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l);
// Localized Color Clusters, as in Select › Color Range: the colour only matches within Range
// (a percentage of the canvas's longer side) of where it was picked.
let points = sample_points(p).map_err(|m| bad(CMD, m))?;
let localized = if p.get("localized").and_then(Value::as_bool).unwrap_or(false) {
if points.is_empty() {
return Err(bad(CMD, "localized clusters need eyedropper `points` (where the colour was picked)"));
}
let canvas = s.active().ok_or(EngineError::NoDocument)?.doc.bounds();
Some(num(p, "range", 100.0).clamp(0.0, 100.0) / 100.0 * canvas.width().max(canvas.height()) as f32)
} else {
None
};
rgba_edit(s, "Replace Color", p, |px, r| match localized {
Some(radius) => {
let samples: Vec<_> = points
.iter()
.map(|&(x, y)| photocraft_algo::selection::RangeSample { color: [c[0], c[1], c[2]], at: Some((x - r.x0 as f32, y - r.y0 as f32)) })
.collect();
let mask = photocraft_algo::selection::color_range_samples(px, r.width() as usize, &samples, fuzz, Some(radius));
tone::replace_color_masked(px, &mask, h, sat, l);
}
None => {
tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l);
}
})
}
/// The `points` of a command (`[[x, y], …]` in document pixels), or a message saying what is wrong.
fn sample_points(p: &Value) -> std::result::Result<Vec<(f32, f32)>, String> {
let Some(pts) = p.get("points") else { return Ok(Vec::new()) };
let pts = pts.as_array().ok_or("`points` must be [[x, y], …]")?;
pts.iter()
.map(|pt| {
pt.as_array()
.and_then(|a| match a.as_slice() {
[x, y] => Some((x.as_f64()? as f32, y.as_f64()? as f32)),
_ => None,
})
.filter(|(x, y)| x.is_finite() && y.is_finite())
.ok_or_else(|| format!("bad point {pt} in `points` (want [x, y])"))
})
.collect()
}
/// Straight RGBA pixels and an optional coverage mask for a document's layer (or its composite).
/// Pixels plus an optional coverage mask.
type Sample = (Vec<[f32; 4]>, Option<Vec<f32>>);
@@ -454,7 +494,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.adjustments.replaceColor",
"Replace Color…",
["Image", "Adjustments"],
r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##,
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##,
has_pixels,
replace_color
),
+35
View File
@@ -503,3 +503,38 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() {
assert!(s.execute("image.adjustments.invert", mask).is_err());
assert_eq!(rgba(&s, 23, 3), pixels);
}
#[test]
fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
// The same cream on the left (x < 8) and the right (x ≥ 40), grey between.
let cream = [0.9, 0.88, 0.79, 1.0];
let mut s = session(8, "rgb");
paint(&mut s, |x, _| if !(8..40).contains(&x) { cream } else { [0.5, 0.5, 0.5, 1.0] });
let base = |s: &Session| (rgba(s, 3, 16), rgba(s, 44, 16), rgba(s, 20, 16));
let (left, right, grey) = base(&s);
let shift = json!({"color": "#e6e0c9", "fuzziness": 20, "hue": 180, "saturation": 60});
// Without clusters the colour is replaced everywhere (unchanged behaviour).
s.execute("image.adjustments.replaceColor", shift.clone()).unwrap();
assert!(changed(rgba(&s, 3, 16), left) > 0.05 && changed(rgba(&s, 44, 16), right) > 0.05);
s.execute("edit.undo", json!({})).unwrap();
// Picked on the left with Range 50 % (24 px): only the left cream changes.
let mut local = shift.clone();
local["points"] = json!([[2, 16]]);
local["localized"] = json!(true);
local["range"] = json!(50);
s.execute("image.adjustments.replaceColor", local.clone()).unwrap();
assert!(changed(rgba(&s, 3, 16), left) > 0.05, "near the sample");
assert_eq!(rgba(&s, 44, 16), right, "the same colour far from the sample");
assert_eq!(rgba(&s, 20, 16), grey);
s.execute("edit.undo", json!({})).unwrap();
// Inside a selection that doesn't start at the origin the sample stays where it was picked.
s.execute("select.rect", json!({"x": 1, "y": 4, "width": 46, "height": 24})).unwrap();
s.execute("image.adjustments.replaceColor", local).unwrap();
assert!(changed(rgba(&s, 3, 16), left) > 0.05);
assert_eq!(rgba(&s, 44, 16), right);
// Clusters need a place to be near.
let err = s.execute("image.adjustments.replaceColor", json!({"localized": true})).unwrap_err();
assert!(err.to_string().contains("points"), "{err}");
let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err();
assert!(err.to_string().contains("bad point"), "{err}");
}
+18 -4
View File
@@ -73,7 +73,7 @@ pub fn default_for(kind: &str, mode: ColorMode) -> Result<Adjustment> {
/// None or of another kind). `mode` picks the Levels/Curves channel space when neither the params
/// nor the base name one.
pub fn from_params(kind: &str, p: &Value, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
let cmd = format!("adjustment `{kind}`");
let cmd = format!("{kind} adjustment");
if !p.is_object() && !p.is_null() {
return Err(bad(&cmd, "params must be an object"));
}
@@ -952,8 +952,10 @@ pub fn parse_curve(cmd: &str, key: &str, v: &Value) -> Result<Vec<CurvePoint>> {
let mut pts = Vec::with_capacity(a.len());
for q in a {
let q = q.as_array().filter(|q| q.len() == 2).ok_or_else(err)?;
let x = q.first().and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(err)?;
let y = q.get(1).and_then(Value::as_f64).filter(|y| y.is_finite()).ok_or_else(err)?;
// Like Photoshop, a point outside 0..255 is refused rather than moved onto the edge.
let level = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|v| (-1e-6..=255.0 + 1e-6).contains(v));
let x = level(q.first()).ok_or_else(err)?;
let y = level(q.get(1)).ok_or_else(err)?;
pts.push(CurvePoint { input: (x as f32).clamp(0.0, 255.0) / 255.0, output: (y as f32).clamp(0.0, 255.0) / 255.0 });
}
pts.sort_by(|a, b| a.input.total_cmp(&b.input));
@@ -1212,6 +1214,17 @@ mod tests {
assert!(matches!(a, Adjustment::Levels { master, .. } if master.in_white > master.in_black));
}
#[test]
fn curve_points_outside_0_255_are_refused_as_the_message_says() {
// Photoshop refuses them too ("the settings in the file are invalid"); they used to be
// moved onto the edge silently.
for pts in [json!([[0, 0], [300, 255]]), json!([[0, -1], [255, 255]]), json!([[0, 0]])] {
let e = from_params("curves", &json!({ "points": pts }), None, ColorMode::Rgb).unwrap_err().to_string();
assert!(e.contains("[input, output] pairs in 0..255") && e.contains("`curves adjustment`"), "{e}");
}
assert!(from_params("curves", &json!({"points": [[0, 0], [128.5, 140], [255, 255]]}), None, ColorMode::Rgb).is_ok());
}
fn curves_parts(a: &Adjustment) -> (&[CurvePoint], &[Vec<CurvePoint>; 3]) {
match a {
Adjustment::Curves { master, per_channel, .. } => (master, per_channel),
@@ -1625,7 +1638,8 @@ mod tests {
#[test]
fn destructive_levels_dont_clip_in_32_bit() {
// Input 15..230, gamma 1.3, output 10..245: integer documents stop at the output range,
// while float documents preserve the curve's signed and HDR values beyond it.
// while float documents preserve the curve's signed and HDR values beyond it (the
// compose-side `levels_dont_clip_in_32_bit` pins the same curve, #2592).
let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245});
for depth in [8, 16, 32] {
let mut s = crate::Session::new();
+110 -6
View File
@@ -17,6 +17,9 @@
//! Laplacian pyramid, each coefficient from the layer sharpest at that scale
//! ([`pyramid::fuse_stack`]; `haloControl` keeps a bright object's defocused glow out of the
//! coarse levels).
//! Stack Images can also add a hidden Depth layer, the layer each pixel is sharpest in by depth
//! from focus ([`photocraft_algo::dff`]): black = the bottom layer, white = the top, so that once
//! on a layer mask it drives Lens Blur's depth map.
use photocraft_algo::panorama::Layout;
use photocraft_algo::pyramid;
@@ -169,6 +172,10 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, format!("`haloControl` must be a number from 0 to {}", pyramid::HALO_MAX))),
},
};
let want_depth = p.get("depthMap").and_then(Value::as_bool).unwrap_or(false);
if want_depth && method != "stack" {
return Err(bad(cmd, "`depthMap` needs `method`: stack"));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc: std::sync::Arc<Document> = st.doc.clone();
let mut area = Rect::EMPTY;
@@ -223,9 +230,33 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let blended = seamless.then(|| b.composite.read_region(area));
(weights, blended)
};
// The depth map: the layer each pixel is sharpest in, from the focus volume guided by the
// merged picture (or, without one, by the per-pixel pick), as a hidden gray layer, black =
// the bottom layer, white = the top, ready for Lens Blur's depth map once on a mask.
let depth_px = want_depth.then(|| {
let n = fmt.channels();
let guide: Vec<f32> = match &blended {
Some(px) => (0..w * h)
.map(|i| {
let c = to_rgba(&fmt, &px[i * n..(i + 1) * n]);
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
})
.collect(),
None => (0..w * h).map(|i| luma.iter().zip(&weights).map(|(l, wt)| l[i] * wt[i]).sum()).collect(),
};
let refs: Vec<&[f32]> = luma.iter().map(Vec::as_slice).collect();
let dm = photocraft_algo::dff::depth_from_focus(&refs, &guide, w, h, &photocraft_algo::dff::DepthParams::default());
let scale = 1.0 / (ids.len() - 1).max(1) as f32;
let mut out = vec![0.0f32; w * h * n];
for (i, d) in dm.depth.iter().enumerate() {
let g = (d * scale).clamp(0.0, 1.0);
photocraft_raster::from_rgba_into(&fmt, [g, g, g, 1.0], &mut out[i * n..(i + 1) * n]);
}
out
});
let top = *ids.last().ok_or_else(|| EngineError::Other("Auto-Blend needs two or more layers".into()))?;
let label = "Auto-Blend Layers";
let new_layer = s.edit(label, |doc, active| {
let (new_layer, depth_layer) = s.edit(label, |doc, active| {
for (id, wt) in ids.iter().zip(&weights) {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
let mut mask = LayerMask::hide_all();
@@ -233,17 +264,33 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
mask.surface.prune();
l.mask = Some(mask);
}
let Some(px) = &blended else { return Ok(None) };
let mut below = top;
let depth_id = depth_px.as_ref().map(|px| {
let mut l = Layer::raster(doc.next_layer_name("Depth"), fmt);
l.visible = false;
if let Some(surf) = l.surface_mut() {
surf.write_region(area, px);
surf.prune();
}
below = doc.insert_above(Some(below), l);
below
});
let Some(px) = &blended else {
if depth_id.is_some() {
*active = depth_id;
}
return Ok((None, depth_id));
};
let mut l = Layer::raster(doc.next_layer_name("Blended"), fmt);
if let Some(surf) = l.surface_mut() {
surf.write_region(area, px);
surf.prune();
}
let nid = doc.insert_above(Some(top), l);
let nid = doc.insert_above(Some(below), l);
*active = Some(nid);
Ok(Some(nid))
Ok((Some(nid), depth_id))
})?;
Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "blended": new_layer.map(|l| l.0)}))
Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "blended": new_layer.map(|l| l.0), "depth": depth_layer.map(|l| l.0)}))
}
macro_rules! spec {
@@ -260,7 +307,12 @@ pub fn specs() -> Vec<CommandSpec> {
r##"{"projection":"auto|perspective|cylindrical|spherical|collage|reposition","reference":layer id?=bottom selected layer,"geometricCorrection":bool=false,"interpolation":"bicubic|bilinear|nearest","registration":"auto|features|intensity"}"##,
auto_align
),
spec!("edit.autoBlendLayers", "Auto-Blend Layers…", r##"{"method":"panorama|stack","seamlessTones":bool=true,"haloControl":0..8=2}"##, auto_blend),
spec!(
"edit.autoBlendLayers",
"Auto-Blend Layers…",
r##"{"method":"panorama|stack","seamlessTones":bool=true,"haloControl":0..8=2,"depthMap":bool=false}"##,
auto_blend
),
]
}
@@ -533,4 +585,56 @@ mod tests {
}
}
}
/// Three layers, each sharp in one third: the Depth layer says which, black to white from
/// the bottom layer to the top, hidden, and undone with the rest.
#[test]
fn auto_blend_stack_depth_map() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 96, "height": 48, "depth": depth, "background": "transparent"})).unwrap();
let mut ids = Vec::new();
for k in 0..3 {
s.execute("layer.new.layer", json!({"name": format!("L{k}")})).unwrap();
s.edit("third", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for y in 0..48 {
for x in 0..96 {
let v = if x / 32 == k && ((x / 2) + (y / 2)) % 2 == 0 {
0.2
} else if x / 32 == k {
0.8
} else {
0.5
};
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, v, v, 1.0]);
}
}
Ok(())
})
.unwrap();
ids.push(s.active().unwrap().active_layer.unwrap());
}
select(&mut s, &ids);
assert!(s.execute("edit.autoBlendLayers", json!({"method": "panorama", "depthMap": true})).is_err());
for seamless in [false, true] {
let r = s.execute("edit.autoBlendLayers", json!({"method": "stack", "seamlessTones": seamless, "depthMap": true})).unwrap();
let doc = &s.active().unwrap().doc;
let d = doc.layer(LayerId(r["depth"].as_u64().unwrap())).unwrap();
assert!(!d.visible && d.name.starts_with("Depth"), "depth {depth}: {} visible {} {r}", d.name, d.visible);
let surf = d.surface().unwrap();
for (x, want) in [(8, 0.0), (48, 0.5), (88, 1.0)] {
let px = surf.pixel(x, 24);
assert!((px[0] - want).abs() < 0.2 && (px[3] - 1.0).abs() < 1e-3, "depth {depth} seamless {seamless} x {x}: {px:?}");
}
assert_eq!(r["blended"].is_null(), !seamless);
if seamless {
// The merged layer sits above the depth layer and is the active one.
assert_eq!(s.active().unwrap().active_layer, Some(LayerId(r["blended"].as_u64().unwrap())));
}
s.undo();
assert!(s.active().unwrap().doc.layer(LayerId(r["depth"].as_u64().unwrap())).is_none());
}
}
}
}
+70 -1
View File
@@ -10,7 +10,10 @@ use photocraft_doc::LayerContent;
use photocraft_geom::Rect;
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect};
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, PresetCaptures, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke};
use photocraft_paint::tile::PREVIEW_SIDE;
use photocraft_paint::{
BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke,
};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -773,6 +776,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new(), captures });
// The saved brush is the current brush now (Photoshop selects the brush it just saved).
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
}
@@ -786,6 +790,8 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) {
s.tools.current_preset = None;
// The remembered brush names the preset it was picked from: the name went away.
s.tools.brush_changed();
}
s.brush_presets_changed();
Ok(json!({ "count": s.tools.presets.len() }))
@@ -823,6 +829,7 @@ fn presets_set_current(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no brush preset named `{name}`")));
}
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name }))
}
@@ -889,6 +896,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "width": tw, "height": th }))
}
@@ -948,9 +956,70 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
None => s.tools.brush_gesture = None,
}
// The one accepted brush change everyone (UI sliders, control channel, presets) lands here.
s.tools.brush_changed();
Ok(brush_json(&s.tools.brush))
}
/// Remember the current brush in `Preferences::last_brush` (see [`remembered_brush`]). Called
/// every frame while the pointer is up, so it is change-driven: `ToolState::brush_rev` is compared
/// with the revision already saved and an unchanged brush clones and serialises nothing.
pub fn remember_brush(s: &mut Session) {
if s.tools.brush_rev == s.tools.brush_saved_rev {
return;
}
let Some(now) = remembered_brush(s) else { return };
if s.prefs().last_brush != now {
s.prefs.edit(|p| p.last_brush = now);
}
s.tools.brush_saved_rev = s.tools.brush_rev;
}
/// The current brush as JSON for `Preferences::last_brush`: settings plus tip references, no full
/// bitmap. `None` while a sampled bitmap too big to embed is current (only before the store
/// externalizes it; see [`ref_tip`]), so the previous remembered brush stands.
fn remembered_brush(s: &Session) -> Option<Value> {
let b = &s.tools.brush;
let (tip, dual, pattern) = (ref_tip(&b.tip)?, ref_tip(&b.dual_brush.tip)?, ref_pattern(&b.texture.pattern)?);
let mut c = b.clone();
(c.tip, c.dual_brush.tip, c.texture.pattern) = (tip, dual, pattern);
Some(json!({ "preset": s.tools.current_preset.clone(), "brush": serde_json::to_value(&c).ok()? }))
}
/// Restore `Preferences::last_brush` into the session at launch (a missing or malformed value
/// keeps the default brush). Stored tips load when the preset store attaches.
pub fn restore_brush(s: &mut Session) {
let v = s.prefs().last_brush.clone();
let Some(patch) = v.get("brush").filter(|b| b.is_object()) else { return };
let Ok(mut b) = merge_brush(&BrushSettings::default(), patch, "brush.restore") else { return };
if validate_brush(&b, "brush.restore").is_err() {
return;
}
let _ = s.load_brush_tips(&mut b);
s.tools.brush = b;
s.tools.current_preset = v.get("preset").and_then(Value::as_str).map(str::to_string);
// What was just restored is exactly what the preferences hold: nothing new to write.
s.tools.brush_saved_rev = s.tools.brush_rev;
}
/// A tip for remembering: a loaded stored tip drops its full bitmap (it serialises as a reference
/// then); a sampled bitmap bigger than a preview would enter the prefs document whole.
fn ref_tip(t: &TipShape) -> Option<TipShape> {
match t {
TipShape::Stored(r) if r.full.is_some() => Some(TipShape::Stored(StoredTile { full: None, ..r.clone() })),
TipShape::Sampled(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None,
t => Some(t.clone()),
}
}
fn ref_pattern(p: &Pattern) -> Option<Pattern> {
match p {
Pattern::Stored(r) if r.full.is_some() => Some(Pattern::Stored(StoredTile { full: None, ..r.clone() })),
Pattern::Tile(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None,
p => Some(p.clone()),
}
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
+76
View File
@@ -910,6 +910,82 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() {
assert!(r.get("fromLive").is_none());
}
#[test]
fn last_brush_is_remembered_when_the_revision_moves_only() {
let mut s = session(8, 8);
restore_brush(&mut s);
// A restore is already in the preferences: nothing to write, no clone or serialise.
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"), "an unchanged brush must not serialise");
s.execute("tools.setBrush", json!({"size": 23})).unwrap();
assert!(s.tools.brush_rev > s.tools.brush_saved_rev);
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["brush"]["size"].as_f64(), Some(23.0));
assert_eq!(s.tools.brush_saved_rev, s.tools.brush_rev, "the revision is marked saved");
// Revision unchanged: even a hand-written value stays (the gate is a counter compare).
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"));
}
#[test]
fn rejected_brush_changes_do_not_move_the_revision() {
let mut s = session(8, 8);
let rev = s.tools.brush_rev;
assert!(s.execute("tools.setBrush", json!({"size": "big"})).is_err());
assert_eq!(s.tools.brush_rev, rev, "a rejected brush leaves no change behind");
s.execute("tools.setBrush", json!({"size": 31})).unwrap();
assert!(s.tools.brush_rev > rev);
}
#[test]
fn the_revision_tracks_the_picked_preset_too() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round"})).unwrap();
let rev = s.tools.brush_rev;
s.execute("brush.presets.setCurrent", json!({"name": "Soft Round"})).unwrap();
assert!(s.tools.brush_rev > rev, "the remembered preset name follows the picked preset");
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["preset"].as_str(), Some("Soft Round"));
}
#[test]
fn the_remembered_brush_restores_at_launch() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round", "size": 44, "hardness": 0.3})).unwrap();
remember_brush(&mut s);
let stored = s.prefs().last_brush.clone();
assert_eq!(stored["preset"].as_str(), Some("Hard Round"));
let mut t = Session::new();
assert_ne!(t.tools.brush.size, 44.0, "a fresh session starts on the default brush");
t.prefs.edit(|p| p.last_brush = stored);
restore_brush(&mut t);
assert_eq!(t.tools.brush.size, 44.0);
assert_eq!(t.tools.brush.hardness, 0.3);
assert_eq!(t.tools.current_preset.as_deref(), Some("Hard Round"));
assert_eq!(t.tools.brush_saved_rev, t.tools.brush_rev, "the restored value is already stored");
// A malformed remembered brush keeps the default (never an error, never a panic).
let mut bad = Session::new();
bad.prefs.edit(|p| p.last_brush = json!({"brush": {"size": "huge"}}));
restore_brush(&mut bad);
assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size);
}
#[test]
fn remembered_brushes_never_embed_large_bitmaps() {
use std::sync::Arc;
let big = GrayTile::from_fn(PREVIEW_SIDE + 1, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(big)).is_none(), "a big sampled tip cannot be embedded");
let small = GrayTile::from_fn(8, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(small.clone())).is_some(), "small tiles embed");
let stored =
StoredTile { key: "k".into(), width: 300, height: 200, preview: Arc::new(small), full: Some(Arc::new(GrayTile::from_fn(300, 200, |_, _| 1.0))) };
let t = ref_tip(&TipShape::Stored(stored)).expect("stored tips are remembered as references");
assert!(matches!(t, TipShape::Stored(r) if r.full.is_none()), "a loaded full bitmap is dropped");
assert!(ref_pattern(&Pattern::Tile(GrayTile::from_fn(PREVIEW_SIDE + 1, 4, |_, _| 1.0))).is_none());
}
/// Photoshop's Clear painting mode (#3439): a Brush or Pencil stroke in `"mode": "clear"` erases
/// with that brush, and History still names the tool rather than the Eraser.
#[test]
+2
View File
@@ -119,6 +119,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
pr.builtin = false;
if was_current {
s.tools.current_preset = Some(new.clone());
s.tools.brush_changed();
}
s.brush_presets_changed();
}
@@ -433,6 +434,7 @@ fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
s.tools.presets.retain(|x| !in_folder(x, &g, &f));
if s.tools.current_preset.as_ref().is_some_and(|c| !s.tools.presets.iter().any(|x| x.name.eq_ignore_ascii_case(c))) {
s.tools.current_preset = None;
s.tools.brush_changed();
}
s.brush_presets_changed();
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
+16
View File
@@ -147,3 +147,19 @@ fn cut_a_colour_channel_clears_only_that_channel() {
s.undo();
assert!(near(&px(&s, 5, 5), &left), "one undoable step");
}
#[test]
fn clearing_a_channel_reports_allocation_failure_without_editing() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let id = s.active().unwrap().active_layer.unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.entries();
crate::allocation::fail_next_for_test();
let result = s.edit("Clear", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, 0, [1.0; 4]));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().history.entries(), history);
}
}
+5 -3
View File
@@ -152,6 +152,7 @@ pub(crate) fn fix_view(st: &mut DocState) {
fn touch(st: &mut DocState) {
let clean = st.saved_revision == st.revision;
st.revision += 1;
st.view_revision += 1;
if clean {
st.saved_revision = st.revision;
}
@@ -746,15 +747,16 @@ pub(crate) fn clear_color_channel(doc: &mut Document, id: LayerId, k: usize, bac
if area.is_empty() {
return Ok(());
}
let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1)));
let mut px = surf.read_region(area);
let cover =
sel.as_ref().map(|m| crate::allocation::read_region(m, area, "clearing a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?;
let mut px = crate::allocation::read_region(surf, area, "clearing a channel")?;
for (i, p) in px.chunks_exact_mut(n).enumerate() {
let w = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
if let Some(c) = p.get_mut(k) {
*c += (v - *c) * w.clamp(0.0, 1.0);
}
}
surf.write_region(area, &px);
surf.try_write_region(area, &px)?;
surf.prune();
Ok(())
}
+83
View File
@@ -315,6 +315,60 @@ impl ColorState {
}
}
/// Whether opening `doc` asks about its profile first (Ask When Opening, for a mismatched or
/// missing profile), and if so what the prompt shows: the report [`Self::open_policy`]
/// would give, with `ask: true` and `action` the answer the policy suggests. Nothing is
/// changed: the answer is applied with [`Self::resolve_open`] before the document opens.
pub fn open_question(&self, doc: &Document) -> Option<Value> {
let space = mode_space(doc.mode);
if !matches!(space, ColorSpace::Rgb | ColorSpace::Cmyk | ColorSpace::Gray) {
return None;
}
let working = self.working(doc.mode);
let policy = self.settings.policy(doc.mode);
let embedded = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space);
match embedded {
Some(emb) => {
let linear_hdr =
doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray()));
if !self.settings.ask_on_mismatch || emb.same_colors(&working) || linear_hdr {
return None;
}
let action = match policy {
Policy::Preserve => "preserve",
Policy::Off => "discard",
Policy::Convert => "convert",
};
Some(
json!({"embedded": emb.description, "working": working.description, "mismatch": true, "policy": policy.id(), "action": action, "ask": true}),
)
}
None if self.settings.ask_on_missing => {
Some(json!({"working": working.description, "policy": policy.id(), "missing": true, "action": "preserve", "ask": true}))
}
None => None,
}
}
/// Applies the answer to an [`Self::open_question`] to a document not opened yet:
/// `preserve` (keep the embedded profile, or leave an untagged file as is), `convert` to the
/// working space, `discard` the embedded profile, or `assignWorking` (untagged files).
pub fn resolve_open(&self, doc: &mut Document, action: &str) -> Result<()> {
match action {
"preserve" | "useEmbedded" => Ok(()),
"convert" | "convertToWorking" => convert_document(doc, &self.working(doc.mode), self.settings.intent(), self.settings.bpc),
"discard" | "off" => {
doc.icc_profile = None;
Ok(())
}
"assignWorking" => {
doc.icc_profile = Some(self.working(doc.mode).to_bytes());
Ok(())
}
other => Err(EngineError::Other(format!("unknown answer `{other}` (preserve|convert|discard|assignWorking)"))),
}
}
/// Proofing state of a document (defaults: coated CMYK, relative colorimetric + BPC, off).
pub fn proof(&self, doc: DocId) -> ProofView {
self.proofs.get(&doc).cloned().unwrap_or_else(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() })
@@ -1398,6 +1452,35 @@ mod settings_tests {
// The Blend Text Colors Using Gamma test is `tests/text_gamma.rs`: it changes the
// process-wide text gamma, which would race every unit test here that composites type.
#[test]
fn open_question_asks_only_when_set_and_resolve_open_applies_each_answer() {
let mut s = Session::new();
// Ask When Opening off: nothing to ask, mismatched or missing.
s.execute("edit.colorSettings", json!({"askOnMismatch": false, "askOnMissing": false})).unwrap();
assert!(s.color.open_question(&tagged("adobe-rgb-compat")).is_none());
let mut untagged = tagged("srgb");
untagged.icc_profile = None;
assert!(s.color.open_question(&untagged).is_none());
// On: a mismatch asks, suggesting the policy's answer; a matching profile never asks.
s.execute("edit.colorSettings", json!({"askOnMismatch": true, "askOnMissing": true, "policyRgb": "convert"})).unwrap();
let q = s.color.open_question(&tagged("adobe-rgb-compat")).unwrap();
assert_eq!((q["mismatch"].as_bool(), q["action"].as_str()), (Some(true), Some("convert")));
assert!(q["embedded"].as_str().unwrap().starts_with("Adobe RGB"));
assert!(s.color.open_question(&tagged("srgb")).is_none());
assert_eq!(s.color.open_question(&untagged).unwrap()["missing"], true);
// Asking changes nothing; the answer is applied to the document.
let mut d = tagged("adobe-rgb-compat");
s.color.resolve_open(&mut d, "preserve").unwrap();
assert!(desc(&d).unwrap().starts_with("Adobe RGB"));
s.color.resolve_open(&mut d, "convert").unwrap();
assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1"));
s.color.resolve_open(&mut d, "discard").unwrap();
assert_eq!(desc(&d), None);
s.color.resolve_open(&mut d, "assignWorking").unwrap();
assert_eq!(desc(&d).as_deref(), Some("sRGB IEC61966-2.1"));
assert!(s.color.resolve_open(&mut d, "explode").is_err());
}
#[test]
fn open_policies() {
let mut s = Session::new();
+21 -9
View File
@@ -573,7 +573,7 @@ fn build() -> Vec<CommandSpec> {
"Layer…",
["Layer", "New"],
Some("Cmd+Shift+N"),
r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error)}"##,
r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)}"##,
has_doc,
crate::new_layer::run
),
@@ -601,9 +601,25 @@ fn build() -> Vec<CommandSpec> {
"Duplicate Layer…",
["Layer"],
None,
r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it),"name":str? (one layer: the copy's name)} (no layer: every selected layer)"##,
r##"{"layer":id?,"inPlace":bool=false (artboards: keep the copy on the original instead of beside it),"name":str? (one layer: the copy's name),"document":index|"new"=same document (Photoshop's Destination: "new" makes a new document the size of this canvas holding just the copies),"documentName":str? (the new document's name)} (no layer: every selected layer)"##,
has_layer,
|s, p| {
if let Some(d) = p.get("document") {
if d.as_str() == Some("new") {
return crate::layer_copy_cmds::duplicate_to_new_document(s, p);
}
let same = d.as_u64().is_some_and(|i| s.active_index().is_some_and(|a| i == a as u64));
if !same {
return Err(EngineError::BadParams {
cmd: "layer.duplicate".into(),
msg: if d.as_u64().is_some() {
"the destination is another open document (use layer.copyToDocument)".into()
} else {
"`document` must be a document index or \"new\"".into()
},
});
}
}
let in_place = p.get("inPlace").and_then(Value::as_bool).unwrap_or(false);
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string);
if crate::layer_multi_cmds::multi(s, p) {
@@ -701,13 +717,8 @@ fn build() -> Vec<CommandSpec> {
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
match k.as_str() {
"transparency" => l.locks.transparency = v,
"pixels" => l.locks.pixels = v,
"position" => l.locks.position = v,
"artboard" => l.locks.artboard = v,
"all" => l.locks.all = v,
other => return Err(bad("layer.setProps", format!("unknown lock `{other}`"))),
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
}
}
}
@@ -1299,6 +1310,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::analysis_cmds::specs());
v.extend(crate::notes_cmds::specs());
v.extend(crate::history_cmds::specs());
v.extend(crate::history_select_cmds::specs());
v.extend(crate::proof_sim::specs());
v.extend(crate::presets::specs());
v.extend(crate::document_preset_cmds::specs());
+24 -2
View File
@@ -45,8 +45,12 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
s,
"Remove Background",
move |doc, _, ctx| {
ctx.progress(0.1, "Finding subject");
let found = with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject(smp, d.bounds()));
// Cancel reaches the min cuts inside Select Subject, so Esc doesn't wait for them.
let found = ctx
.stage(0.1, 0.6, "Finding subject", |ctl| {
with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject_with(smp, d.bounds(), ctl))
})
.map_err(|_| EngineError::Cancelled)?;
let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?;
if ctx.cancelled() {
return Err(EngineError::Cancelled);
@@ -200,6 +204,24 @@ mod tests {
assert!(l.mask.is_some());
}
#[test]
fn remove_background_cancelled_while_finding_the_subject_changes_nothing() {
let (mut s, _) = disc(8);
let id = active_id(&s);
let before = s.active().unwrap().revision;
// The job's cancel reaches Select Subject, which stops at its first check.
let ctx = crate::jobs::JobCtx::new();
ctx.cancel();
s.set_inline_job_ctx(Some(ctx.clone()));
assert!(matches!(s.execute(CMD, json!({})), Err(EngineError::Cancelled)));
assert_eq!(ctx.message(), "Finding subject");
assert_eq!(s.active().unwrap().revision, before);
assert!(layer(&s, id).mask.is_none());
s.set_inline_job_ctx(None);
s.execute(CMD, json!({})).unwrap();
assert!(layer(&s, id).mask.is_some());
}
#[test]
fn remove_background_without_a_subject_leaves_the_document() {
let mut s = session_with(32, 32, 8, |_, _| [0.5, 0.5, 0.5]);
+27 -9
View File
@@ -76,14 +76,15 @@ impl CanvasDisplay {
if !self.encode_srgb {
return Cow::Borrowed(buf);
}
let mut b = buf.clone();
for p in &mut b.px {
for v in &mut p[..3] {
let mut px = vec![[0.0f32; 4]; buf.px.len()];
par_pixels(&mut px, &buf.px, |out, src| {
for (o, p) in out.iter_mut().zip(src) {
// Capped at the largest half float; NaN is dropped by the texel conversion.
*v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0));
let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0));
*o = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]];
}
}
Cow::Owned(b)
});
Cow::Owned(Buffer { rect: buf.rect, px })
}
}
@@ -97,12 +98,29 @@ fn encode_rgba8(buf: &Buffer) -> Rgba8Image {
let t = encode_table();
let mut img = Rgba8Image::new(buf.rect.width(), buf.rect.height());
let code = |v: f32| t.get((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize).copied().unwrap_or(255);
for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&buf.px) {
*o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8];
}
par_pixels(img.pixels.as_chunks_mut::<4>().0, &buf.px, |out, src| {
for (o, p) in out.iter_mut().zip(src) {
*o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8];
}
});
img
}
/// Pixels per piece [`par_pixels`] hands to one thread.
const PAR_PIXELS: usize = 16 * 1024;
/// `f` over matching pieces of `dst` and `src`, in parallel off wasm. The display encodes run on
/// every canvas upload and preview, and a 24 MP linear document took 350 ms on one core.
fn par_pixels<D: Send, S: Sync>(dst: &mut [D], src: &[S], f: impl Fn(&mut [D], &[S]) + Sync) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
dst.par_chunks_mut(PAR_PIXELS).zip(src.par_chunks(PAR_PIXELS)).for_each(|(d, s)| f(d, s));
}
#[cfg(target_arch = "wasm32")]
f(dst, src);
}
/// In-place 8-bit RGBA transform (alpha kept), in blocks so the scratch copy stays small.
fn apply_u8(t: &Transform, px: &mut [u8]) {
const BLOCK: usize = 1 << 20;
+58
View File
@@ -397,3 +397,61 @@ fn proof_colors_still_apply_on_top() {
s.execute("view.gamutWarning", json!({"on": true})).unwrap();
assert_ne!(sig, s.color.display_signature(&d));
}
#[test]
fn parallel_linear_encodes_match_the_scalar_curve() {
// Several parallel pieces, a ragged last one, and HDR, negative and NaN values.
let n: usize = 3 * 16 * 1024 + 77;
let px: Vec<[f32; 4]> = (0..n)
.map(|i| {
let v = match i % 9 {
0 => f32::NAN,
1 => -0.5,
2 => 3.25,
_ => (i % 1001) as f32 / 1000.0,
};
[v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0]
})
.collect();
let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px };
let mut doc = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::F32);
doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes());
let mut color = crate::color_cmds::ColorState::default();
color.settings.monitor_profile = "srgb".into();
let d = color.canvas_display(&doc).unwrap();
assert!(d.encode_srgb);
let enc = |v: f32| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 65504.0));
let tex = d.texture_buffer(&buf);
assert_eq!(tex.rect, buf.rect);
for (t, p) in tex.px.iter().zip(&buf.px) {
let want = [enc(p[0]), enc(p[1]), enc(p[2]), p[3]];
assert!(t.iter().zip(want).all(|(a, b)| a.to_bits() == b.to_bits()), "{p:?} -> {t:?}, want {want:?}");
}
let code = |v: f32| (photocraft_color::convert::linear_to_srgb(((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize) as f32 / 65535.0) * 255.0 + 0.5) as u8;
let img = d.to_rgba8(&buf);
for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) {
assert_eq!(*o, [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8], "{p:?}");
}
}
#[test]
fn parallel_plain_quantize_matches_the_scalar_rounding() {
let n: usize = 3 * 16 * 1024 + 77;
let px: Vec<[f32; 4]> = (0..n)
.map(|i| {
let v = match i % 9 {
0 => f32::NAN,
1 => -0.5,
2 => 3.25,
_ => (i % 1001) as f32 / 1000.0,
};
[v, (i % 257) as f32 / 256.0, 1.0 - (i % 113) as f32 / 112.0, (i % 5) as f32 / 4.0]
})
.collect();
let buf = photocraft_compose::Buffer { rect: Rect::new(-4, 2, -4 + n as i32, 3), px };
let img = buf.to_rgba8();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
for (o, p) in img.pixels.as_chunks::<4>().0.iter().zip(&buf.px) {
assert_eq!(*o, p.map(q), "{p:?}");
}
}
+20 -2
View File
@@ -10,9 +10,11 @@
//! resampling. The params are the data in every case (strokes, pins and planes are small
//! compared with a field or a mesh, and replay is deterministic), so nothing needs a blob.
use std::cell::RefCell;
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell};
use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten};
use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp};
use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask};
use photocraft_algo::transform::Interp;
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface};
use photocraft_color::PixelFormat;
@@ -328,7 +330,23 @@ fn puppet(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
let it = interp(p);
let too_far = || bad(cmd, "the pins stretch the content too far: the result would cover more than 2^30 pixels");
run_on_layer(s, cmd, "Puppet Warp", p, true, &|surf, sel, _| deform_selected(surf, sel, &|x| puppet_surface(x, &w, it).ok_or_else(too_far)), None)
// A linked layer mask moves with the pixels on the same mesh (#4910): the layer's warp
// solves the mesh once and leaves the moved mask here for the mask step.
let linked = s.active().and_then(|d| d.doc.layer(id)).and_then(|l| l.mask.as_ref()).filter(|m| m.linked).map(|m| m.surface.clone());
let moved_mask: RefCell<Option<Surface>> = RefCell::new(None);
let layer_fn = |surf: &Surface, sel: Option<&Surface>, _: Rect| {
deform_selected(surf, sel, &|x| {
let b = x.content_bounds();
if b.is_empty() {
return Ok(x.clone());
}
let (out, mask) = puppet_warp_with_mask(x, b, &w, it, linked.as_ref()).ok_or_else(too_far)?;
*moved_mask.borrow_mut() = mask;
Ok(out)
})
};
let mask_fn = |m: &Surface, _: Option<&Surface>| Ok(moved_mask.borrow_mut().take().unwrap_or_else(|| m.clone()));
run_on_layer(s, cmd, "Puppet Warp", p, true, &layer_fn, Some(&mask_fn))
}
fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
+62
View File
@@ -124,6 +124,68 @@ fn puppet_identity_translation_and_undo() {
}
}
fn mask_at(s: &Session, x: i32, y: i32) -> f32 {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0)
}
/// [`session`] with a reveal-all mask that hides the stripes `stripes` (x ranges, y 10..40).
fn masked_session(depth: u64, stripes: &[(i32, i32)]) -> Session {
let mut s = session(depth);
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.edit("mask", |doc, active| {
let m = doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap();
for &(x0, x1) in stripes {
m.surface.fill_rect(Rect::new(x0, 10, x1, 40), &[0.0]);
}
Ok(())
})
.unwrap();
s
}
#[test]
fn puppet_moves_a_linked_mask_with_the_pixels() {
// #4910: Puppet Warp warped only the pixels and left a linked mask behind. A one-pin rigid
// warp moves the block by (6, 4); the stripe the mask hides (x 20..24) must move with it.
for depth in [8u64, 16, 32] {
let mut s = masked_session(depth, &[(20, 24)]);
let before = s.active().unwrap().doc.clone();
s.execute(PUPPET, json!({"pins": [{"src": [30, 25], "dst": [36, 29]}], "mode": "rigid", "interpolation": "bilinear"})).unwrap();
for y in [16, 30, 42] {
assert!(mask_at(&s, 27, y) < 0.01, "{depth}: the stripe moved to x 26..30 (y {y})");
assert!(mask_at(&s, 21, y) > 0.99, "{depth}: and left x 20..24 (y {y})");
}
assert!(mask_at(&s, 27, 12) > 0.99, "{depth}: and down by 4");
// Pixels and mask still line up: the same layer pixel is hidden as before the warp.
let was = before.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().pixel(21, 26);
assert!(worst_px(&active_surface(&s).pixel(27, 30), &was) <= 1.0 / 255.0, "{depth}");
s.undo();
assert!(mask_at(&s, 21, 30) < 0.01 && mask_at(&s, 27, 30) > 0.99, "{depth}: undo puts the mask back");
}
}
#[test]
fn puppet_leaves_an_unlinked_mask_in_place() {
let mut s = masked_session(8, &[(20, 24)]);
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
s.execute(PUPPET, json!({"pins": [{"src": [30, 25], "dst": [36, 29]}], "mode": "rigid"})).unwrap();
assert!(mask_at(&s, 21, 30) < 0.01 && mask_at(&s, 27, 30) > 0.99);
assert_eq!(active_surface(&s).content_bounds(), Rect::new(16, 14, 66, 44), "the pixels still moved");
}
#[test]
fn puppet_with_a_selection_moves_only_the_selected_part_of_a_linked_mask() {
// The selection lifts the block's left part (x 10..30): the stripe hidden there moves with
// it, the one outside (x 40..44) stays over its pixels.
let mut s = masked_session(8, &[(20, 24), (40, 44)]);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 60})).unwrap();
s.execute(PUPPET, json!({"pins": [{"src": [20, 25], "dst": [26, 29]}], "mode": "rigid"})).unwrap();
assert!(mask_at(&s, 27, 30) < 0.01 && mask_at(&s, 21, 30) > 0.99, "the selected stripe moved");
assert!(mask_at(&s, 41, 30) < 0.01 && mask_at(&s, 45, 30) > 0.99, "the other stayed");
assert_eq!(active_surface(&s).pixel(41, 30)[3], 1.0, "over its pixels");
}
#[test]
fn puppet_with_an_expansion_that_empties_the_mesh_keeps_the_layer() {
// #951: a pinned warp over an empty mesh panicked in the solver; it must not erase the layer
+60
View File
@@ -881,6 +881,32 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
Ok(Value::Null)
}
/// Farthest the ruler zero point may sit from the image, in document pixels.
const MAX_RULER_ORIGIN: f64 = 1.0e7;
/// Move the rulers' zero point (`view.setRulerOrigin`), or put it back at the image's top-left
/// (`view.resetRulerOrigin`, `p` = None). Undoable like the guides.
fn ruler_origin_cmd(s: &mut Session, p: Option<&Value>) -> Result<Value> {
let origin = match p {
None => [0.0, 0.0],
Some(p) => {
let axis = |k: &str| {
p.get(k)
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && v.abs() <= MAX_RULER_ORIGIN)
.ok_or_else(|| EngineError::Other(format!("`{k}` must be a number of pixels within ±{MAX_RULER_ORIGIN}")))
};
[axis("x")?, axis("y")?]
}
};
let label = if p.is_some() { "Set Ruler Origin" } else { "Reset Ruler Origin" };
s.edit(label, |doc, _| {
doc.ruler_origin = origin;
Ok(())
})?;
Ok(json!({"x": origin[0], "y": origin[1]}))
}
pub fn specs() -> Vec<CommandSpec> {
macro_rules! spec {
($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => {
@@ -987,6 +1013,18 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")),
spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")),
spec!(
"view.setRulerOrigin",
"Set Ruler Origin",
&[],
None,
r##"{"x":px,"y":px} (the rulers' zero point in document pixels; the rulers and Info panel measure from it)"##,
has_doc,
|s, p| ruler_origin_cmd(s, Some(p))
),
spec!("view.resetRulerOrigin", "Reset Ruler Origin", &[], None, "{} (zero point back at the image's top-left)", has_doc, |s, _| ruler_origin_cmd(
s, None
)),
]
}
@@ -1915,6 +1953,28 @@ mod tests {
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
/// #2955: the rulers' zero point is set and reset by command, undoable, and bad params are
/// refused without touching the document.
#[test]
fn ruler_origin_is_set_reset_and_undoable() {
let mut s = session();
assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]);
assert_eq!(s.execute("view.setRulerOrigin", json!({"x": 40, "y": 12.5})).unwrap(), json!({"x": 40.0, "y": 12.5}));
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
s.execute("view.resetRulerOrigin", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.ruler_origin, [0.0, 0.0]);
s.undo();
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
for bad in [json!({}), json!({"x": 1}), json!({"x": "a", "y": 2}), json!({"x": 1e300, "y": 0}), json!({"x": -2e7, "y": 0}), json!(null), json!([1, 2])]
{
assert!(s.execute("view.setRulerOrigin", bad.clone()).is_err(), "{bad} is refused");
}
assert_eq!(s.active().unwrap().doc.ruler_origin, [40.0, 12.5]);
let mut empty = Session::new();
assert!(empty.execute("view.setRulerOrigin", json!({"x": 1, "y": 2})).is_err());
assert!(empty.execute("view.resetRulerOrigin", json!({})).is_err());
}
/// The last guide added in `orientation` for `position`.
fn new_guide(s: &mut Session, orientation: &str, position: Value) -> f64 {
s.execute("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap();
+8 -7
View File
@@ -151,14 +151,14 @@ fn surface_of(doc: &Document, id: LayerId, mask: bool) -> Option<&Surface> {
/// Blend `after` back towards `before` over `area`: `before + opacity × (B(before, after) − before)`
/// with blend mode `mode` (premultiplied, so alpha changes fade too).
pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: BlendMode, opacity: f32) {
pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: BlendMode, opacity: f32) -> Result<()> {
let fmt = after.format();
let n = fmt.channels();
if area.is_empty() || n == 0 {
return;
return Ok(());
}
let b = before.read_region(area);
let mut a = after.read_region(area);
let b = crate::allocation::read_region(before, area, "fading pixels")?;
let mut a = crate::allocation::read_region(after, area, "fading pixels")?;
let k = opacity.clamp(0.0, 1.0);
let lerp_premul = |pb: &[f32], pa: &[f32], out: &mut [f32], alpha: bool| {
let m = out.len();
@@ -176,7 +176,7 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble
}
out[m - 1] = ao;
};
let mut tmp = vec![0.0f32; n];
let mut tmp = crate::allocation::filled(n, 0.0f32, "fading pixels")?;
for (pa, pb) in a.chunks_exact_mut(n).zip(b.chunks_exact(n)) {
if mode == BlendMode::Normal || n == 1 {
lerp_premul(pb, pa, &mut tmp, fmt.alpha);
@@ -192,7 +192,8 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble
pa.copy_from_slice(&enc[..n]);
}
}
after.write_region(area, &a);
after.try_write_region(area, &a)?;
Ok(())
}
fn fade(s: &mut Session, p: &Value) -> Result<Value> {
@@ -212,7 +213,7 @@ fn fade(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(&label, |doc, _| {
let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?;
let surf = if src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(src.layer))?;
fade_surface(&before, surf, area, mode, opacity);
fade_surface(&before, surf, area, mode, opacity)?;
surf.prune();
Ok(())
})?;
+20
View File
@@ -544,3 +544,23 @@ fn backward_change_find_moves_past_the_replaced_text() {
assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(0), Some(3)), "{r}");
assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(8), Some(11)), "{r}");
}
#[test]
fn fade_allocation_failure_keeps_document_and_history() {
for depth in [8, 16, 32] {
let mut s = session(depth);
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.entries();
crate::allocation::fail_next_for_test();
let result = s.execute("edit.fade", json!({"opacity": 25}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().history.entries(), history);
assert!(s.is_enabled("edit.fade"));
s.execute("edit.fade", json!({"opacity": 25})).unwrap();
assert!(s.undo());
assert_eq!(*s.active().unwrap().doc, *before);
}
}
+22 -8
View File
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
use crate::{EngineError, Result, Session};
mod batch_naming;
// ---------- predicates ----------
fn has_doc(s: &Session) -> std::result::Result<(), String> {
@@ -931,19 +933,25 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
/// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format`
/// (`"same"` keeps the input's extension). Errors per file are collected, not fatal.
pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|path| {
let ext =
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
(path.as_str(), join(output, &format!("{}{suffix}.{ext}", stem(path))))
});
process_outputs(outputs, save, f)
}
fn process_outputs<'a>(outputs: impl IntoIterator<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let mut files = Vec::new();
let mut errors = Vec::new();
let mut written = OutputClaims::default();
for path in inputs {
let ext =
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
let out = join(output, &format!("{}{suffix}.{ext}", stem(path)));
for (path, out) in outputs {
let r = (|| -> Result<()> {
written.check(&out).map_err(EngineError::Other)?;
let bytes = read_file(path)?;
let mut scratch = Session::new();
let doc = import(&file_name(path), &bytes)?;
scratch.add_document(doc, Some(path.clone()));
scratch.add_document(doc, Some(path.to_string()));
f(&mut scratch)?;
let d = scratch.active().ok_or(EngineError::NoDocument)?;
save_doc(&d.doc, &out, save)?;
@@ -1010,7 +1018,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() };
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?, "", &|scratch| {
let names = batch_naming::plan(&inputs, &output, &format, p)?;
let save = SaveOpts::from_params(s, p, cmd)?;
let run = |scratch: &mut Session| {
scratch.actions = actions.clone();
for (id, params) in &steps {
let r = scratch.execute(id, params.clone())?;
@@ -1020,7 +1030,11 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
}
}
Ok(())
});
};
let r = match names {
Some(names) => process_outputs(inputs.iter().map(String::as_str).zip(names), save, &run),
None => process_files(&inputs, &output, &format, save, "", &run),
};
Ok(r)
}
@@ -1535,7 +1549,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
batch
),
+123
View File
@@ -0,0 +1,123 @@
//! Batch output names are planned before opening or writing files. Names substituted from an
//! input are literal: braces in a source filename must never be interpreted as template tokens.
use super::{EngineError, Result, Value, join, stem};
fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: "file.automate.batch".into(), msg: message.into() }
}
enum Part<'a> {
Literal(&'a str),
Name,
Serial,
Date,
}
fn parse(template: &str) -> Result<Vec<Part<'_>>> {
let mut parts = Vec::new();
let mut rest = template;
while let Some(at) = rest.find(['{', '}']) {
parts.push(Part::Literal(&rest[..at]));
rest = &rest[at..];
if rest.starts_with('}') {
return Err(bad("naming: unmatched closing brace"));
}
let end = rest.find('}').ok_or_else(|| bad("naming: unclosed token"))?;
parts.push(match &rest[1..end] {
"name" => Part::Name,
"serial" => Part::Serial,
"date" => Part::Date,
token => return Err(bad(format!("naming: unknown token {{{token}}}; use {{name}}, {{serial}} or {{date}}"))),
});
rest = &rest[end + 1..];
}
parts.push(Part::Literal(rest));
Ok(parts)
}
fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
let mut out = String::new();
for part in parts {
match part {
Part::Literal(s) => out.push_str(s),
Part::Name => out.push_str(name),
Part::Serial => out.push_str(&format!("{serial:03}")),
Part::Date => out.push_str(date),
}
}
out
}
/// An absent or empty template preserves the old filename path exactly. An explicit template
/// generates one component per input; the export format supplies the extension separately.
pub(super) fn plan(inputs: &[String], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
let start = match params.get("startingSerial") {
None => 1,
Some(n) => {
n.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|&n| n > 0).ok_or_else(|| bad("startingSerial must be an integer from 1 to 4294967295"))?
}
};
let template = match params.get("naming") {
None => "",
Some(Value::String(s)) => s,
Some(_) => return Err(bad("naming must be a string")),
};
if template.is_empty() {
return Ok(None);
}
if template.len() > 255 {
return Err(bad("naming must be at most 255 bytes"));
}
let parts = parse(template)?;
let has_serial = parts.iter().any(|p| matches!(p, Part::Serial));
let timestamp = crate::analysis_cmds::now_iso();
let date = timestamp.get(..10).unwrap_or("");
if date.is_empty() && parts.iter().any(|p| matches!(p, Part::Date)) {
return Err(bad("naming: the current UTC date is unavailable"));
}
let mut outputs = Vec::with_capacity(inputs.len());
for (index, path) in inputs.iter().enumerate() {
let serial = if has_serial {
u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))?
} else {
start
};
let base = expand(&parts, &stem(path), serial, date);
if base.is_empty() || base.ends_with(['.', ' ']) {
return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space"));
}
let ext =
if format == "same" { path.rsplit('.').next().unwrap_or("png").to_ascii_lowercase() } else { format.trim_start_matches('.').to_ascii_lowercase() };
let name = format!("{base}.{ext}");
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
}
outputs.push(join(output, &name));
}
// Renaming within the source folder could replace a later input before it is read. Resolve
// existing paths so aliases such as `source/./001.png` cannot bypass the check. The default
// (no template) path keeps its existing in-place behavior.
#[cfg(not(target_arch = "wasm32"))]
{
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|p| std::fs::canonicalize(p).ok()).collect();
for output in &outputs {
if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) {
return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template")));
}
}
}
Ok(Some(outputs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substituted_names_are_literal_and_serials_do_not_wrap_or_truncate() {
let parts = parse("{date}_{name}_{serial}").unwrap();
assert_eq!(expand(&parts, "photo{serial}", 7, "2026-10-13"), "2026-10-13_photo{serial}_007");
assert_eq!(expand(&parts, "photo", 1000, "2026-10-13"), "2026-10-13_photo_1000");
}
}
+128
View File
@@ -946,6 +946,34 @@ fn templates_open_untitled_without_their_path() {
assert_eq!(opened, [("Untitled-1", None), ("Untitled-2", None)]);
}
#[test]
fn batch_naming_serial_keeps_same_named_inputs_and_their_pixels() {
let dir = tmp("naming-serial");
let first = join(&dir, "first");
let second = join(&dir, "second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
let inputs = [png(&first, "photo.png", 8, 8, "#ff0000"), png(&second, "photo.png", 8, 8, "#0000ff")];
let out = join(&dir, "out");
let mut s = Session::new();
let result = s
.execute(
"file.automate.batch",
json!({
"input": inputs, "output": out, "steps": [], "format": "png",
"naming": "{name}_{serial}", "startingSerial": 7
}),
)
.unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["files"], json!([join(&out, "photo_007.png"), join(&out, "photo_008.png")]));
for (name, expected) in [("photo_007.png", [1.0, 0.0, 0.0, 1.0]), ("photo_008.png", [0.0, 0.0, 1.0, 1.0])] {
let saved = photocraft_io::import(name, &std::fs::read(join(&out, name)).unwrap()).unwrap().document;
let pixel = photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0];
assert_eq!(pixel, expected, "{name}");
}
}
/// Two inputs with one output name: the first result is kept and the second is an error, never
/// a silent overwrite listed as written (#422).
#[test]
@@ -1019,3 +1047,103 @@ fn reveal_in_finder_reveals_the_documents_saved_path() {
assert_eq!(r["program"], json!(program));
assert_eq!(r["args"], json!(args));
}
#[test]
fn batch_naming_validates_the_whole_plan_before_writing() {
let dir = tmp("naming-invalid");
let input = png(&dir, "source.png", 8, 8, "#ff0000");
let mut s = Session::new();
for (index, extra) in [
json!({"naming": "../{name}"}),
json!({"naming": "sub\\{name}"}),
json!({"naming": "C:{name}"}),
json!({"naming": "{unknown}"}),
json!({"naming": "{name"}),
json!({"naming": "name}"}),
json!({"naming": 42}),
json!({"naming": "{serial}", "startingSerial": 0}),
json!({"naming": "{serial}", "startingSerial": 1.5}),
json!({"naming": "{serial}", "startingSerial": "1"}),
json!({"naming": "{serial}", "startingSerial": 4294967295_u64}),
json!({"naming": "x".repeat(256)}),
]
.into_iter()
.enumerate()
{
let out = join(&dir, &format!("out-{index}"));
let mut params = json!({"input": [input, input], "output": out, "steps": []});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let error = s.execute("file.automate.batch", params).unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }), "{error}");
assert!(!std::path::Path::new(&out).exists(), "invalid naming wrote outputs: {extra}");
}
}
#[test]
fn batch_naming_keeps_serial_positions_after_errors_and_captures_the_utc_date() {
let dir = tmp("naming-errors");
let input = png(&dir, "good.png", 8, 8, "#0000ff");
let out = join(&dir, "out");
let before = crate::analysis_cmds::now_iso();
let mut s = Session::new();
let result = s
.execute(
"file.automate.batch",
json!({
"input": [join(&dir, "missing.png"), input], "output": out, "steps": [],
"naming": "{date}_{serial}_{name}", "startingSerial": 9
}),
)
.unwrap();
let after = crate::analysis_cmds::now_iso();
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{result}");
let name = file_name(files[0].as_str().unwrap());
assert!(
name == format!("{}_010_good.png", &before[..10]) || name == format!("{}_010_good.png", &after[..10]),
"the missing input still consumes its serial: {name}"
);
}
#[test]
fn batch_naming_still_reports_collisions_instead_of_overwriting() {
let dir = tmp("naming-collision");
let red = png(&dir, "red.png", 8, 8, "#ff0000");
let blue = png(&dir, "blue.png", 8, 8, "#0000ff");
let out = join(&dir, "out");
let mut s = Session::new();
let result = s
.execute(
"file.automate.batch",
json!({
"input": [red, blue], "output": out, "steps": [], "naming": "constant"
}),
)
.unwrap();
assert_eq!(result["files"], json!([join(&out, "constant.png")]));
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written"));
let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant.png")).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
let dir = tmp("naming-input-alias");
let first = png(&dir, "002.png", 8, 8, "#ff0000");
let second = png(&dir, "001.png", 8, 8, "#0000ff");
let before = [std::fs::read(&first).unwrap(), std::fs::read(&second).unwrap()];
let mut s = Session::new();
// Without preflight, processing 002 first writes 001, so the next input's original pixels
// are lost before it is read. The full plan must be rejected without writing either file.
let error = s
.execute(
"file.automate.batch",
json!({
"input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}"
}),
)
.unwrap_err();
assert!(error.to_string().contains("input"), "{error}");
assert_eq!(std::fs::read(&first).unwrap(), before[0]);
assert_eq!(std::fs::read(&second).unwrap(), before[1]);
}
+19 -2
View File
@@ -100,7 +100,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc_bounds = st.doc.bounds();
let sel = st.doc.selection.clone();
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc_bounds);
let area = sel.as_ref().map_or(doc_bounds, |m| m.content_bounds().intersect(&doc_bounds));
let mut out = json!({ "contents": contents });
let restore = contents == "history";
let source = match contents {
@@ -148,7 +148,6 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(format!("unknown contents `{contents}`"))),
};
let area = area.intersect(&doc_bounds);
if area.is_empty() {
return Ok(out);
}
@@ -529,6 +528,24 @@ mod tests {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
}
#[test]
fn history_fill_reads_only_the_selection_inside_the_canvas() {
let mut s = session();
s.edit("selection", |doc, _| {
let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8);
selection.write_pixel(-2_000_000_000, -2_000_000_000, &[1.0]);
selection.write_pixel(4, 4, &[1.0]);
doc.selection = Some(selection);
Ok(())
})
.unwrap();
// Reading the full sparse selection rectangle overflows the allocation size;
// only its intersection with this small canvas is meaningful to Fill.
s.execute(CMD, json!({"contents": "history"})).unwrap();
assert!(s.undo());
assert!(s.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(4, 4, 0) > 0.0);
}
#[test]
fn every_constant_contents() {
let mut s = session();
+9 -1
View File
@@ -8,7 +8,8 @@
//! - `select.drop` drops the piece into its layer and moves the selection with it, as one history
//! step.
//! - Any other command drops the piece first (so it lands exactly where it was shown), except Undo,
//! which puts it back where it was cut (nothing was changed yet).
//! which puts it back where it was cut (nothing was changed yet), and `history.selectState`,
//! which puts it back and then returns to the chosen state.
//!
//! While floating, the document is unchanged; [`displayed`] gives the document with the piece at
//! its offset, for the canvas. The selection stays where it was until the drop.
@@ -221,6 +222,13 @@ pub(crate) fn before_command(s: &mut Session, id: &str) -> Result<Option<Value>>
st.floating = None;
Ok(Some(json!({"floating": "returned"})))
}
// Returning to a History state puts the piece back too (it never reached the document),
// and then moves the history: dropping it first would add a step and discard the redo
// states the jump may be heading for.
"history.selectState" => {
st.floating = None;
Ok(None)
}
_ if keeps_floating(id) => Ok(None),
_ => drop_floating(s).map(|_| None),
}
+258
View File
@@ -0,0 +1,258 @@
//! History panel › click a state: `history.selectState` returns the document to that state in one
//! step (#2966), as Photoshop does. The panel used to run Edit › Undo / Redo once per state: each
//! bumped the document revision, so the canvas could no longer tell what the jump changed and
//! recomposited (and re-uploaded) the whole document, plus N rounds of per-command bookkeeping.
//!
//! States are numbered as the History panel lists them, like `history.deleteState`: 0 is the
//! oldest state still held, `past` (the number of undo states) is the current one, and the redo
//! states (dimmed in the panel) follow it. Jumping back leaves the skipped states redoable;
//! jumping forward redoes them. Nothing is discarded until the next edit.
use serde_json::{Value, json};
use crate::commands::{CommandSpec, int};
use crate::{EngineError, Result, Session};
const CMD: &str = "history.selectState";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
fn select_state(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let past = st.history.past_len();
let last = past.saturating_add(st.history.redo_labels().count());
let v = match p.get("state") {
None | Some(Value::Null) => return Err(bad("missing `state` (the History entry to return to, 0 = oldest)")),
Some(_) => int(p, "state").ok_or_else(|| bad("`state` must be a number"))?,
};
let target = usize::try_from(v).ok().filter(|i| *i <= last).ok_or_else(|| bad(format!("`state` = {v} is not a state (0..={last})")))?;
// A background job is computing from the current state: it must not move under it.
if s.active_job().is_some() {
return Err(EngineError::Other("the history can't move while a command is still running on this document".into()));
}
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
if target == past {
return Ok(json!({ "state": target, "changed": false }));
}
st.coalesce = None;
// Step the history itself (pointer moves, no pixels touched), then make the reached state
// current once: one revision, one damage rect for the canvas.
let start = st.doc.clone();
let mut doc = start.clone();
let mut layers = None;
let mut at = past;
while at != target {
let step = if target < at { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) };
let Some((d, l)) = step else { break };
doc = d;
layers = Some(l);
at = if target < at { at - 1 } else { at + 1 };
}
let Some(layers) = layers else {
return Err(EngineError::Other("the history can't step right now".into()));
};
// Save As changes file identity outside history, as Edit › Undo keeps it.
if doc.name != start.name {
std::sync::Arc::make_mut(&mut doc).name.clone_from(&start.name);
}
let damage = crate::layer_multi_cmds::step_damage(&start, &doc);
st.doc = doc;
crate::restore_target(st, layers);
st.revision += 1;
st.last_damage = damage;
if at != target {
// Not reached (the bounds were checked): the document is consistent at the state reached.
return Err(EngineError::Other(format!("the history stopped at state {at}")));
}
Ok(json!({ "state": target, "changed": true, "current": st.history.entries().last() }))
}
fn can_select(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or_else(|| "no document open".to_string())?;
if st.history.can_undo() || st.history.can_redo() { Ok(()) } else { Err("the history holds only one state".into()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
label: "Select History State",
menu: &[],
shortcut: None,
params: r##"{"state":index} (History entry as the panel lists them: 0 = oldest, then the current state, then the redo states) → {state,changed,current?}: returns the document to that state in one step; the states after it stay redoable"##,
enabled: can_select,
run: select_state,
journal: true,
}]
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
/// A document with four steps after Open, each targeting different layers.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s
}
type Look = (Arc<photocraft_doc::Document>, Option<photocraft_doc::LayerId>, Vec<photocraft_doc::LayerId>, Vec<String>, Vec<String>);
/// What the panel can see of a session: the document, the targeted layers, the entries and
/// the redo states.
fn look(s: &Session) -> Look {
let st = s.active().unwrap();
(st.doc.clone(), st.active_layer, st.selected_layers.clone(), st.history.entries(), st.history.redo_labels().map(str::to_string).collect())
}
/// Step from the current state to `target` with Edit › Undo / Redo, note what that gives, and
/// step back.
fn stepped(s: &mut Session, target: usize) -> Look {
let current = s.active().unwrap().history.past_len();
let (there, back) = if target < current { ("edit.undo", "edit.redo") } else { ("edit.redo", "edit.undo") };
let n = target.abs_diff(current);
for _ in 0..n {
s.execute(there, json!({})).unwrap();
}
let seen = look(s);
for _ in 0..n {
s.execute(back, json!({})).unwrap();
}
seen
}
fn same(a: &Look, b: &Look) {
assert!(Arc::ptr_eq(&a.0, &b.0), "the same document");
assert_eq!((&a.1, &a.2, &a.3, &a.4), (&b.1, &b.2, &b.3, &b.4));
}
#[test]
fn jumping_back_and_forward_matches_stepping() {
for target in 0..=4usize {
for from_back in [0usize, 2, 4] {
// Start `from_back` states before the end, then jump to `target`.
let mut s = session();
for _ in 0..from_back {
s.execute("edit.undo", json!({})).unwrap();
}
let current = 4 - from_back;
let expected = stepped(&mut s, target);
let rev = s.active().unwrap().revision;
let r = s.execute(CMD, json!({"state": target})).unwrap();
assert_eq!(r["state"], json!(target));
assert_eq!(r["changed"], json!(target != current));
same(&look(&s), &expected);
assert_eq!(s.active().unwrap().history.past_len(), target);
let bumps = s.active().unwrap().revision - rev;
assert_eq!(bumps, u64::from(target != current), "{from_back} → {target}: one revision for the jump");
}
}
}
#[test]
fn a_jump_reports_only_the_pixels_it_changed() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 300})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.rect", json!({"x": 40, "y": 30, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
let past = s.active().unwrap().history.past_len();
s.execute(CMD, json!({"state": past - 4})).unwrap();
let damage = s.active().unwrap().last_damage.expect("a bounded damage rect, not a full recomposite");
assert!(damage.width() < 400 && damage.height() < 300, "{damage:?}");
// And forward again.
s.execute(CMD, json!({"state": past})).unwrap();
assert!(s.active().unwrap().last_damage.is_some());
}
#[test]
fn redo_states_survive_a_jump_and_go_with_the_next_edit() {
let mut s = session();
let all = s.active().unwrap().history.entries();
s.execute(CMD, json!({"state": 1})).unwrap();
let (_, _, _, entries, redo) = look(&s);
assert_eq!(entries, all[..2].to_vec());
assert_eq!(redo, all[2..].to_vec(), "the later states are dimmed, next one first");
assert!(s.is_enabled(CMD));
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
assert!(!s.active().unwrap().history.can_redo(), "a new step discards them");
}
#[test]
fn select_state_restores_the_layers_the_state_targeted() {
let mut s = session();
let bottom = s.active().unwrap().doc.layers.first().unwrap().id;
// Target the bottom layer, then jump back to the first fill: its layer comes back, as
// with Undo.
s.select_layer(bottom).unwrap();
let expected = stepped(&mut s, 2);
s.select_layer(bottom).unwrap();
s.execute(CMD, json!({"state": 2})).unwrap();
same(&look(&s), &expected);
assert_ne!(s.active().unwrap().active_layer, Some(bottom));
}
#[test]
fn a_floating_piece_goes_back_and_the_jump_still_happens() {
let mut s = session();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 8, "height": 8})).unwrap();
s.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap();
let all = s.active().unwrap().history.entries();
let doc = s.active().unwrap().doc.clone();
assert!(s.active().unwrap().floating.is_some());
s.execute(CMD, json!({"state": 1})).unwrap();
let st = s.active().unwrap();
assert!(st.floating.is_none(), "the piece went back, it wasn't dropped");
assert_eq!(st.history.past_len(), 1);
let redo: Vec<String> = st.history.redo_labels().map(str::to_string).collect();
assert_eq!(redo, all[2..].to_vec(), "no Drop step was added");
// Forward to the state the piece was floated on: the document as it was, without the piece.
s.execute(CMD, json!({"state": all.len() - 1})).unwrap();
assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc));
}
#[test]
fn select_state_refuses_bad_params_and_changes_nothing() {
let mut s = Session::new();
assert!(s.execute(CMD, json!({"state": 0})).is_err(), "no document");
assert!(!s.is_enabled(CMD));
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
assert!(!s.is_enabled(CMD), "only the opening state");
let mut s = session();
s.execute("edit.undo", json!({})).unwrap();
let before = look(&s);
let rev = s.active().unwrap().revision;
for p in [
json!({}),
json!({"state": null}),
json!({"state": -1}),
json!({"state": 5}),
json!({"state": 99}),
json!({"state": i64::MAX}),
json!({"state": f64::MAX}),
json!({"state": "x"}),
json!({"state": "1"}),
json!({"state": [1]}),
json!({"state": {"i": 1}}),
json!({"state": true}),
json!([1]),
] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
let now = look(&s);
assert!(Arc::ptr_eq(&now.0, &before.0) && now.3 == before.3 && now.4 == before.4, "{p} changed nothing");
}
assert_eq!(s.active().unwrap().revision, rev);
// The last redo state is reachable.
assert!(s.execute(CMD, json!({"state": 4})).is_ok());
}
}
+35 -9
View File
@@ -38,7 +38,7 @@ pub fn document(d: &DocState) -> Value {
"isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::<Vec<_>>(),
"hasSelection": doc.selection.is_some(),
"selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }),
"layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected)).collect::<Vec<_>>(),
"layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::<Vec<_>>(),
"history": d.history.entries(),
"snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::<Vec<_>>(),
"canUndo": d.history.can_undo(),
@@ -63,11 +63,12 @@ pub fn document(d: &DocState) -> Value {
/// Layer tree top-to-bottom (display order).
pub fn layer(l: &Layer) -> Value {
layer_sel(l, &[])
layer_sel(l, &[], None)
}
/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection).
fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection); with the
/// document's `meta`, a smart object whose file is embedded in the PSD says so.
fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId], meta: Option<&photocraft_doc::Metadata>) -> Value {
let bounds = l.surface().map(|s| {
let r = s.content_bounds();
[r.x0, r.y0, r.width() as i32, r.height() as i32]
@@ -91,14 +92,16 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
});
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected, meta)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
}
}
LayerContent::Adjustment(a) => {
v["adjustment"] = serde_json::to_value(a).unwrap_or(Value::Null);
// The same kind and parameters the adjustment commands take (`inBlack` 0..255, …),
// not the internal model.
v["adjustment"] = json!({"kind": crate::commands::adjustment_kind(a), "params": crate::adjust_params::to_params(a)});
}
LayerContent::Smart(sm) => {
v["smartFilters"] = Value::Array(
@@ -109,11 +112,15 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
);
v["smartFiltersEnabled"] = json!(sm.filters_enabled);
// Where the contents come from. A PSD placed layer (embedded or linked in Photoshop) is
// `linked` to the `Idnt` uuid of its file, which its duplicates share; New Smart Object
// via Copy embeds its own copy of the file instead.
// `linked` to the `Idnt` uuid of its file, which its duplicates share; when the file
// is embedded in the PSD (a `liFD` item) it is reported as embedded, with that uuid.
// New Smart Object via Copy embeds its own copy of the file instead.
v["smartSource"] = match &sm.source {
SmartSource::Embedded { file_name, .. } => json!({"kind": "embedded", "fileName": file_name}),
SmartSource::Linked { path } => json!({"kind": "linked", "path": path}),
SmartSource::Linked { path } => match meta.and_then(|m| photocraft_io::linked::embedded_file_name(m, path)) {
Some(name) => json!({"kind": "embedded", "fileName": name, "uuid": path}),
None => json!({"kind": "linked", "path": path}),
},
};
}
LayerContent::Text(t) => {
@@ -165,6 +172,25 @@ mod tests {
use photocraft_color::PixelFormat;
use photocraft_doc::{LayerMask, Path, VectorMask};
#[test]
fn an_adjustment_layer_is_reported_with_the_params_its_command_takes() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4})).unwrap();
s.execute("layer.newAdjustmentLayer.levels", json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2})).unwrap();
let info = s.execute("document.inspect", json!({})).unwrap();
let adj = &info["layers"][0]["adjustment"];
assert_eq!(adj["kind"], "levels");
assert_eq!((adj["params"]["inBlack"].as_f64(), adj["params"]["inWhite"].as_f64()), (Some(10.0), Some(240.0)), "{adj}");
// Fed back to the command, the params give the same adjustment; no internal field names.
let model = match &s.active().unwrap().doc.layers[1].content {
LayerContent::Adjustment(a) => a.clone(),
c => panic!("{c:?}"),
};
let back = crate::adjust_params::from_params("levels", &adj["params"], None, photocraft_color::ColorMode::Rgb).unwrap();
assert_eq!(crate::adjust_params::to_params(&back), crate::adjust_params::to_params(&model));
assert!(!adj.to_string().contains('_'), "{adj}");
}
#[test]
fn document_inspect_reports_raster_and_vector_masks_when_nested() {
let cases = [(false, None), (true, None), (false, Some(true)), (false, Some(false)), (true, Some(true))];
+11 -1
View File
@@ -412,7 +412,10 @@ impl Session {
/// Open a file in the background: read (for a path) and decode on a worker, with progress
/// (per layer for PSD/PSB) and cancellation, then add the document like
/// [`Session::open_document`] (Color Settings policies apply). `name` names the document.
/// The job's result is `{document, name, warnings, color}`; on wasm it runs inline.
/// The job's result is `{document, name, warnings, color}`; on wasm it runs inline. When
/// Color Settings ask about the file's profile, the document is held back instead and the
/// result is `{held, name, warnings, color}` (`color` the question): take it with
/// [`Session::take_held_open`] once answered.
pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result<Started> {
let label = format!("Opening {name}");
let name_w = name.to_string();
@@ -438,6 +441,13 @@ impl Session {
})
},
move |s, r: photocraft_io::ImportResult| {
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
// developed raw has no embedded profile to ask about.
let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
if let Some(question) = s.color.open_question(&r.document).filter(|_| !raw) {
let held = s.hold_open(r.document, r.source_read_only);
return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question}));
}
let (index, color) = s.open_document(r.document, None);
if let Some(st) = s.active_mut() {
st.source_read_only = r.source_read_only;
+27
View File
@@ -463,6 +463,33 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() {
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_)));
}
/// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes
/// it once the prompt is answered. Without Ask it opens as usual.
#[test]
fn an_open_that_asks_about_its_profile_holds_the_document_back() {
let mut src = session(8, 8);
let mut doc = (*src.active().unwrap().doc).clone();
doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes());
let bytes = Arc::new(photocraft_io::export(&doc, "x.png", &Default::default()).unwrap().bytes);
src.close(0);
let mut s = Session::new();
s.execute("edit.colorSettings", json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap();
let id = job(s.start_open("x.png", OpenSource::Bytes(Arc::clone(&bytes))).unwrap());
let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() };
assert!(s.documents().is_empty(), "held back, not opened");
assert_eq!((v["color"]["mismatch"].as_bool(), v["color"]["ask"].as_bool(), v["color"]["action"].as_str()), (Some(true), Some(true), Some("preserve")));
let held = v["held"].as_u64().unwrap();
let (taken, _) = s.take_held_open(held).expect("the held document");
assert!(taken.icc_profile.is_some());
assert!(s.take_held_open(held).is_none(), "taken once");
s.execute("edit.colorSettings", json!({"askOnMismatch": false})).unwrap();
let id = job(s.start_open("x.png", OpenSource::Bytes(bytes)).unwrap());
let JobOutcome::Done(v) = wait_event(&mut s, id).outcome else { panic!() };
assert!(v.get("held").is_none());
assert_eq!(s.documents().len(), 1);
}
#[test]
fn wait_job_blocks_until_applied() {
let mut s = session(400, 300);
+60
View File
@@ -213,6 +213,66 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "offset": [dx, dy]}))
}
/// `layer.duplicate` with `"document": "new"`: Photoshop's Duplicate Layer… ›
/// Document: New. A new document the size of the source's canvas holds just the duplicated
/// layers at their canvas positions (a transparent background elsewhere); the source is
/// untouched and the new document becomes active. Mode, depth, resolution, profile,
/// patterns and global light come along so the copies render the same.
pub(crate) fn duplicate_to_new_document(s: &mut Session, p: &Value) -> Result<Value> {
let src = s.active_index().ok_or(EngineError::NoDocument)?;
let sdoc = s.documents().get(src).ok_or(EngineError::NoDocument)?.doc.clone();
let ids = match crate::commands::explicit_layer(s, p)? {
Some(id) => vec![id],
None => s.active().ok_or(EngineError::NoDocument)?.selected_layers(),
};
if let Some(missing) = ids.iter().find(|id| sdoc.layer(**id).is_none()) {
return Err(EngineError::NoLayer(*missing));
}
let ids = crate::layer_multi_cmds::top_level(&sdoc, &ids);
if ids.is_empty() {
return Err(bad("no layers to copy"));
}
if !layered(sdoc.mode) {
return Err(EngineError::Other(format!("layers can't be duplicated with a {:?} document", sdoc.mode)));
}
let doc_name = p.get("documentName").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| format!("{} copy", sdoc.name));
let mut nd = Document::new(doc_name, sdoc.size, sdoc.mode, sdoc.depth);
nd.resolution_dpi = sdoc.resolution_dpi;
nd.icc_profile.clone_from(&sdoc.icc_profile);
nd.patterns.clone_from(&sdoc.patterns);
nd.global_light = sdoc.global_light;
for id in &ids {
let mut l = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?.duplicate();
if ids.len() == 1
&& let Some(name) = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty())
{
l.name = name.to_string();
}
nd.layers.push(l);
}
// Contents ids belong to a document: fresh ones keep the copies independent of the
// source while preserving sharing within the batch (as `adapt_copies` does).
let mut contents = HashMap::new();
let smart_ids: Vec<_> = nd.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect();
for id in smart_ids {
if let Some(l) = nd.layer_mut(id)
&& let LayerContent::Smart(sm) = &mut l.content
{
sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh);
}
}
let new_ids: Vec<LayerId> = nd.layers.iter().map(|l| l.id).collect();
let index = s.add_document(nd, None);
crate::layer_multi_cmds::reselect(s, new_ids.clone(), new_ids.last().copied());
let mut out = json!({"document": index, "layers": new_ids.iter().map(|l| l.0).collect::<Vec<_>>()});
if let [one] = new_ids.as_slice()
&& let Some(map) = out.as_object_mut()
{
map.insert("layer".into(), json!(one.0));
}
Ok(out)
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
+113 -1
View File
@@ -1,5 +1,5 @@
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, LayerId, Pattern};
use photocraft_doc::{Document, LayerContent, LayerId, Pattern};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -218,6 +218,118 @@ fn copied_link_groups_are_distinct_from_each_other_and_destination() {
assert_ne!(first, second);
}
/// One painted layer, `depth`-bit RGB, as the only layer above the Background.
fn one_doc(depth: u32) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
let paint = LayerId(s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap());
s.execute("select.rect", json!({"x": 4, "y": 6, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
(s, paint)
}
#[test]
fn duplicate_to_a_new_document_keeps_size_pixels_and_place_at_every_depth() {
for depth in [8, 16, 32] {
let (mut s, paint) = one_doc(depth);
let source_before = doc(&s, 0).clone();
let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap();
assert_eq!(r["document"], 1);
assert_eq!(s.active_index(), Some(1), "the new document becomes active");
let (src, dst) = (doc(&s, 0), doc(&s, 1));
assert_eq!(*src, source_before, "the source is untouched");
assert_eq!(dst.size, src.size, "same canvas, as Photoshop");
assert_eq!((dst.mode, dst.depth, dst.resolution_dpi), (src.mode, src.depth, src.resolution_dpi));
assert_eq!(dst.name, format!("{} copy", src.name));
let layers = r["layers"].as_array().unwrap();
assert_eq!(layers.len(), 1);
let id = LayerId(layers[0].as_u64().unwrap());
assert_eq!(r["layer"], id.0);
assert_ne!(id, paint, "a copy has its own id");
assert_eq!(dst.layers.len(), 1, "just the duplicated layer");
assert_eq!(dst.layers[0].name, "paint");
assert_eq!(bounds(&s, 1, id), Rect::new(4, 6, 14, 16), "same canvas position");
assert_eq!(
dst.layers[0].surface().unwrap().read_region(Rect::new(4, 6, 14, 16)),
src.layer(paint).unwrap().surface().unwrap().read_region(Rect::new(4, 6, 14, 16)),
"depth {depth}: identical pixels"
);
assert_eq!(s.active().unwrap().selected_layers(), vec![id]);
}
}
#[test]
fn duplicate_to_a_new_document_names_the_copy_and_the_document() {
let (mut s, _) = one_doc(8);
let r = s.execute("layer.duplicate", json!({"document": "new", "name": "Ink", "documentName": "Card"})).unwrap();
assert_eq!(doc(&s, 1).name, "Card");
assert_eq!(doc(&s, 1).layers[0].name, "Ink");
assert_eq!(r["layer"].as_u64(), r["layers"][0].as_u64());
}
#[test]
fn duplicate_to_a_new_document_carries_every_selected_layer_in_order() {
let (mut s, paint) = one_doc(8);
let bg = doc(&s, 0).layers[0].id;
s.execute("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap();
s.execute("layer.select", json!({"layer": bg, "mode": "add"})).unwrap();
let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap();
let names: Vec<&str> = doc(&s, 1).layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Background", "paint"]);
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
assert!(r.get("layer").is_none(), "no single layer to report");
// A duplicated Background stays a Background: the new document has no destination one.
let back = &doc(&s, 1).layers[0];
assert!(back.locks.transparency && back.locks.position);
}
#[test]
fn duplicate_to_a_new_document_cuts_smart_objects_loose_as_a_batch() {
let (mut s, _) = one_doc(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let original = s.active().unwrap().active_layer.unwrap();
let sibling = LayerId(s.execute("layer.duplicate", json!({})).unwrap()["layer"].as_u64().unwrap());
let contents = |s: &Session, i: usize, id: LayerId| {
let LayerContent::Smart(sm) = &doc(s, i).layer(id).unwrap().content else { panic!("smart object") };
sm.contents_id
};
assert_eq!(contents(&s, 0, original), contents(&s, 0, sibling), "instances share contents");
s.execute("layer.select", json!({"layer": original.0, "mode": "add"})).unwrap();
s.execute("layer.select", json!({"layer": sibling.0, "mode": "add"})).unwrap();
let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap();
let ids: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|id| LayerId(id.as_u64().unwrap())).collect();
assert_eq!(contents(&s, 1, ids[0]), contents(&s, 1, ids[1]), "sharing survives within the batch");
assert_ne!(contents(&s, 1, ids[0]), contents(&s, 0, original), "independent of the source");
}
#[test]
fn duplicate_destinations_that_are_not_new_fail_cleanly() {
let (mut s, _) = one_doc(8);
for p in [
json!({"document": 1}),
json!({"document": 7}),
json!({"document": true}),
json!({"document": 1.5}),
json!({"document": []}),
json!({"document": {}}),
json!({"document": "other"}),
json!({"document": "new", "layer": 999_999}),
] {
let before = (doc(&s, 0).clone(), s.documents().len(), s.active_index());
let err = s.execute("layer.duplicate", p.clone()).unwrap_err().to_string();
if p == json!({"document": 1}) {
assert!(err.contains("layer.copyToDocument"), "{p}: {err}");
}
assert_eq!(s.active_index(), before.2, "{p}: the source stays active");
assert_eq!(s.documents().len(), before.1, "{p}: no document created");
assert_eq!(*doc(&s, 0), before.0, "{p}: the source is untouched");
}
// One open document: same-document duplicates still work, and stay enabled.
assert!(s.is_enabled("layer.duplicate"));
s.execute("layer.duplicate", json!({})).unwrap();
}
#[test]
fn a_named_duplicate_takes_the_name_in_one_step() {
let (mut s, paint) = two_docs(40, 30, 8);
+27 -7
View File
@@ -639,6 +639,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
layers: _,
channels,
guides: _,
ruler_origin: _,
selection: _,
metadata: _,
global_light,
@@ -1168,13 +1169,7 @@ fn lock_layers(s: &mut Session, p: &Value) -> Result<Value> {
for id in &sel {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
for (k, v) in &given {
match *k {
"transparency" => l.locks.transparency = *v,
"pixels" => l.locks.pixels = *v,
"position" => l.locks.position = *v,
"artboard" => l.locks.artboard = *v,
_ => l.locks.all = *v,
}
l.locks.set(k, *v);
}
}
Ok(())
@@ -2606,6 +2601,31 @@ mod tests {
assert_eq!(s.active().unwrap().history.entries().last().map(|e| e.as_str()), Some("Reverse"));
}
/// #4892: Lock Image Pixels includes Lock Transparent Pixels, so the two are never both on:
/// locking the pixels clears the transparency lock, which stays off while they are locked,
/// through both `layer.setProps` and `layer.lockLayers`.
#[test]
fn locking_pixels_clears_and_holds_off_the_transparency_lock() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let id = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
let locks = |s: &Session| doc(s).layer(id).unwrap().locks;
s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap();
assert!(locks(&s).transparency);
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
assert!(locks(&s).pixels && !locks(&s).transparency, "locking the pixels clears the transparency lock");
s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap();
assert!(!locks(&s).transparency, "and it stays off while the pixels are locked");
s.execute("layer.setProps", json!({"locks": {"transparency": true, "pixels": true}})).unwrap();
assert!(locks(&s).pixels && !locks(&s).transparency, "both at once: the pixel lock wins");
s.execute("layer.lockLayers", json!({"pixels": false})).unwrap();
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
assert!(locks(&s).transparency && !locks(&s).pixels, "with the pixels unlocked it can be set again");
s.execute("layer.lockLayers", json!({"pixels": true})).unwrap();
assert!(locks(&s).pixels && !locks(&s).transparency);
assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err());
}
#[test]
fn inspect_reports_selection() {
let mut s = session(8);
+40
View File
@@ -54,6 +54,7 @@ pub mod group_view_cmds;
pub mod guide_layout;
pub mod hidden_target;
pub mod history_cmds;
pub mod history_select_cmds;
pub mod image_cmds;
pub mod inspect;
pub mod installed_profiles;
@@ -197,6 +198,8 @@ pub struct DocState {
/// Increments on every change; UIs re-render when it moves.
pub revision: u64,
pub saved_revision: u64,
/// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those.
pub view_revision: u64,
/// Area changed by the latest revision (None = assume everything changed).
pub last_damage: Option<photocraft_geom::Rect>,
/// Coalescing key of the latest history step (see [`Session::execute`]'s `coalesce` param).
@@ -242,6 +245,7 @@ impl DocState {
source_read_only: false,
revision: 1,
saved_revision: 1,
view_revision: 0,
last_damage: None,
coalesce: None,
channel_view: Default::default(),
@@ -295,6 +299,13 @@ pub struct ToolState {
/// preset stays "the current brush" so `brush.presets.update` can overwrite it); cleared when
/// the brush is reset or the preset is gone.
pub current_preset: Option<String>,
/// Bumped whenever `brush` or `current_preset` changes ([`ToolState::brush_changed`]). The
/// last-brush persistence watches it instead of comparing the brush itself, so an unchanged
/// brush costs a counter compare per frame, not a clone and serialise.
pub brush_rev: u64,
/// The revision whose value is in `Preferences::last_brush` (a restored one counts as saved):
/// [`brush_rev`](Self::brush_rev) == `brush_saved_rev` means there is nothing new to write.
pub brush_saved_rev: u64,
/// A layer mask is the edit target ([`ToolState::target_mask`]).
pub mask_targeted: bool,
/// The foreground/background pair of the edit target that isn't current: the layer pixels'
@@ -329,6 +340,13 @@ impl ToolState {
/// background (reveal-paint first).
pub const MASK_COLORS: [[f32; 4]; 2] = [[1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
/// Note that `brush` or `current_preset` changed. Every setter of either (commands and the
/// shell's tool-switch paths) calls this; the last-brush persistence only serialises when the
/// revision has moved since the stored value ([`Self::brush_saved_rev`]).
pub fn brush_changed(&mut self) {
self.brush_rev = self.brush_rev.wrapping_add(1);
}
/// Switch the edit target between layer pixels and a layer mask. Like Photoshop, the mask and
/// the pixels keep separate app-wide foreground/background pairs: changing the target puts
/// the current pair aside and brings back the other one, so colours picked while a mask is
@@ -361,6 +379,8 @@ impl Default for ToolState {
mixer: Default::default(),
brush_gesture: None,
current_preset: None,
brush_rev: 0,
brush_saved_rev: 0,
mask_targeted: false,
other_colors: Self::MASK_COLORS,
cmyk_picks: Vec::new(),
@@ -439,6 +459,11 @@ pub struct Session {
/// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it
/// ([`Session::offer_live_stroke`]).
pub(crate) live_commit: Option<brush_cmds::LiveCommit>,
/// Documents a background open decoded but held back until the user answers the profile
/// prompt ([`ColorState::open_question`](color_cmds::ColorState::open_question)): `(id,
/// document, source read-only)`. The shell takes them with [`Session::take_held_open`].
held_opens: Vec<(u64, Document, bool)>,
next_held_open: u64,
}
/// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when
@@ -524,6 +549,21 @@ impl Session {
i
}
/// Holds a decoded document back from opening (see `held_opens`); returns its id.
pub(crate) fn hold_open(&mut self, doc: Document, source_read_only: bool) -> u64 {
self.next_held_open = self.next_held_open.wrapping_add(1);
self.held_opens.push((self.next_held_open, doc, source_read_only));
self.next_held_open
}
/// Takes a document a background open held back for the profile prompt: the document and
/// whether its source is read-only. `None` when there is no such document (already taken).
pub fn take_held_open(&mut self, id: u64) -> Option<(Document, bool)> {
let i = self.held_opens.iter().position(|(h, _, _)| *h == id)?;
let (_, doc, read_only) = self.held_opens.remove(i);
Some((doc, read_only))
}
pub fn close(&mut self, index: usize) -> Option<DocState> {
if index >= self.docs.len() {
return None;
+35 -2
View File
@@ -24,7 +24,8 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
}
}
/// `layer.new.layer`: a pixel layer above the active one, joining the clip group it lands in.
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
@@ -49,6 +50,14 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
};
let neutral =
if fill_neutral { Some(neutral_color(mode).ok_or_else(|| bad(format!("{} has no neutral color to fill with", mode.label())))?.0) } else { None };
let below = match p.get("position") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
Some("above") => false,
Some("below") => true,
_ => return Err(bad("`position` must be above or below")),
},
};
let name = match p.get("name") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
@@ -65,7 +74,11 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
let id = doc.insert_above(*active, layer);
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
@@ -158,6 +171,24 @@ mod tests {
assert_eq!(l.surface().unwrap().tile_count(), 0);
}
/// `position: below` (⌘/Ctrl-click on the panel button) lands under the active layer; the
/// Background stays at the bottom, so below it the layer lands above it.
#[test]
fn position_below_lands_under_the_active_layer() {
let mut s = busy("rgb", 8);
let names = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
s.execute("layer.new.layer", json!({"name": "A"})).unwrap();
let below = s.execute("layer.new.layer", json!({"name": "Below", "position": "below"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(names(&s), ["Background", "Below", "A"]);
assert_eq!(s.active().unwrap().active_layer, Some(photocraft_doc::LayerId(below)));
s.execute("layer.new.layer", json!({"name": "Above", "position": "above"})).unwrap();
assert_eq!(names(&s), ["Background", "Below", "Above", "A"]);
let bg = s.active().unwrap().doc.layers[0].id.0;
s.execute("layer.select", json!({"layer": bg})).unwrap();
s.execute("layer.new.layer", json!({"name": "OverBg", "position": "below"})).unwrap();
assert_eq!(names(&s), ["Background", "OverBg", "Below", "Above", "A"]);
}
/// Bad params are errors, never panics, and change nothing.
#[test]
fn bad_params_are_errors() {
@@ -175,6 +206,8 @@ mod tests {
json!({"fillNeutral": "yes"}),
json!({"clipToPrevious": 1}),
json!({"name": 7}),
json!({"position": "sideways"}),
json!({"position": 1}),
] {
assert!(s.execute("layer.new.layer", p.clone()).is_err(), "{p}");
}
+4
View File
@@ -1007,6 +1007,10 @@ pub struct Preferences {
/// the footer slider's card scale, saved as the user changes them and restored at launch.
/// JSON owned by the shell.
pub brush_picker: Value,
/// The brush the tool was last using (settings and tip references, never the full bitmaps;
/// shaped by `brush_cmds::remembered_brush`), restored at launch so the tool starts where it
/// was left. Written only when `ToolState::brush_rev` moves.
pub last_brush: Value,
/// The tools' options-bar settings (Move › Auto-Select, marquee Feather, tolerance, …) that
/// differ from their defaults, saved as the user changes them and restored at launch, as
/// Photoshop remembers each tool's options between sessions. JSON owned by the shell.
+1
View File
@@ -106,6 +106,7 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
let mut b = pr.brush.clone().picked_over(&s.tools.brush);
s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?;
s.tools.brush = b;
s.tools.brush_changed();
}
// Distinct folder paths the file brought (its Photoshop folders, nested inside `group`).
let folders: std::collections::BTreeSet<&[String]> =
+17 -3
View File
@@ -299,7 +299,7 @@ fn decode_tip_max(bytes: &[u8], max_side: u32) -> Result<GrayTile, String> {
}
let data: Vec<u16> =
if bits == 8 { raw.iter().map(|&v| u16::from(v) * 257).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect() };
Ok(GrayTile { width: w, height: h, data })
Ok(GrayTile { width: w, height: h, data: data.into() })
}
// ------------------------------------------------------------------ group files
@@ -1125,6 +1125,11 @@ impl Session {
}
self.brush_presets_changed();
self.sync_preset_store();
// A brush remembered from the previous run (or picked before the store loaded) may be
// painting with previews; load the full tips now (a missing one keeps its preview).
let mut brush = std::mem::take(&mut self.tools.brush);
let _ = self.load_brush_tips(&mut brush);
self.tools.brush = brush;
if let Some(st) = self.preset_store.as_mut() {
warnings.extend(st.take_warnings());
}
@@ -1139,7 +1144,11 @@ impl Session {
if st.synced_rev != Some(rev) {
st.sync(&mut self.tools.presets);
st.synced_rev = Some(rev);
link_stored_tips(st, &mut self.tools.brush);
if link_stored_tips(st, &mut self.tools.brush) {
// An embedded tip became a store reference: what the last-brush
// persistence would write changed without a command touching the brush.
self.tools.brush_changed();
}
}
st.sync_actions(&self.actions.list, actions_rev);
}
@@ -1172,7 +1181,7 @@ impl Session {
/// After a sync, an embedded bitmap of the tool's brush that the store now holds (the brush was
/// just saved as a preset) becomes the stored tile, keeping its bitmap: the brush then matches its
/// preset (Brushes panel highlight) like a preset picked from the library.
fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) {
fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) -> bool {
let link = |g: &GrayTile| -> Option<StoredTile> {
if !g.is_valid() {
return None;
@@ -1180,24 +1189,29 @@ fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) {
let h = tip_hash(g);
st.tips.contains_key(&h).then(|| StoredTile { key: h, width: g.width, height: g.height, preview: Arc::new(g.preview(PREVIEW_SIDE)), full: None })
};
let mut linked = false;
if let TipShape::Sampled(g) = &b.tip
&& let Some(r) = link(g)
&& let TipShape::Sampled(g) = std::mem::take(&mut b.tip)
{
b.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
if let TipShape::Sampled(g) = &b.dual_brush.tip
&& let Some(r) = link(g)
&& let TipShape::Sampled(g) = std::mem::take(&mut b.dual_brush.tip)
{
b.dual_brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
if let Pattern::Tile(g) = &b.texture.pattern
&& let Some(r) = link(g)
&& let Pattern::Tile(g) = std::mem::take(&mut b.texture.pattern)
{
b.texture.pattern = Pattern::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
linked
}
#[cfg(test)]
+75 -17
View File
@@ -27,6 +27,7 @@ mod content_aware_move;
mod patch;
mod pattern_stamp;
pub use pattern_stamp::live_impressionist as live_pattern_impressionist;
mod periodic;
mod remove;
pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview};
pub use patch::preview as patch_preview;
@@ -207,6 +208,7 @@ fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Resu
/// Source pixels for destination `rect`: a translated read, or a bilinear resample when the
/// clone source is scaled, rotated or flipped.
#[allow(clippy::too_many_arguments)]
fn clone_sample(
pre: &Document,
id: Option<LayerId>,
@@ -214,8 +216,12 @@ fn clone_sample(
which: SampleLayers,
rect: Rect,
map: &crate::presets::clone_source::Mapping,
wrap: bool,
cmd: &str,
) -> Result<Region> {
let read = |rect| {
if wrap { periodic::sample(pre, id, surf, which, rect, cmd) } else { Ok(sample(pre, id, surf, which, rect)) }
};
if map.is_translation() {
let off = map.offset();
let src = rect.translate(off.0, off.1);
@@ -224,17 +230,28 @@ fn clone_sample(
if src.width() != rect.width() || src.height() != rect.height() {
return Err(bad(cmd, format!("source offset [{}, {}] is too far: the sample window leaves the coordinate range", off.0, off.1)));
}
let mut r = sample(pre, id, surf, which, src);
let mut r = read(src)?;
r.rect = rect;
return Ok(r);
}
let src = sample(pre, id, surf, which, map.source_rect(rect));
let src = read(map.source_rect(rect))?;
Ok(crate::presets::clone_source::resample(&src, rect, map, alpha_index(&surf.format())))
}
/// Read-only clone overlay. Shares the stroke sampler without anchoring a source or editing history.
/// Both destination and transformed source allocations are bounded for interactive use.
pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source::Mapping, sample_layer: &str) -> Result<photocraft_compose::Buffer> {
clone_preview_periodic(s, rect, map, sample_layer, false)
}
/// Clone-source overlay with optional source repetition for View > Pattern Preview.
pub fn clone_preview_periodic(
s: &Session,
rect: Rect,
map: &crate::presets::clone_source::Mapping,
sample_layer: &str,
wrap: bool,
) -> Result<photocraft_compose::Buffer> {
if ![map.source.0, map.source.1, map.anchor.0, map.anchor.1].into_iter().chain(map.m).all(|v| v.is_finite() && v.abs() < 1_000_000.0) {
return Err(bad("clone.preview", "invalid preview mapping"));
}
@@ -254,7 +271,7 @@ pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source
let id = d.active_layer.ok_or_else(|| bad("clone.preview", "no active layer"))?;
let surf = d.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad("clone.preview", "no pixel surface"))?;
let which = sample_layers(&json!({"sampleLayer": sample_layer}), "clone.preview")?;
let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, "clone.preview")?;
let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, wrap, "clone.preview")?;
let px = region.data.chunks_exact(surf.format().channels()).map(|p| to_rgba(&surf.format(), p)).collect();
Ok(photocraft_compose::Buffer { rect, px })
}
@@ -305,16 +322,26 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
let from_live = live.is_some();
let map = clone_mapping(s, p, &stroke, CMD)?;
let off = map.offset();
let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
if let Some(l) = live {
*surf = l.surface;
return Ok(l.bounds);
}
let (bounds, cov) = stroke_coverage(&stroke);
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?;
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
})?;
let dmg =
run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
if let Some(l) = live {
*surf = l.surface;
return Ok(l.bounds);
}
if flag(p, "wrap", false) {
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
renderer.push(&stroke.points);
renderer.finish();
let before = surf.clone();
let bounds = renderer.bounds();
return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode }
.draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD));
}
let (bounds, cov) = stroke_coverage(&stroke);
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?;
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
})?;
let mut r = clone_result(dmg, off, aligned, p, &stroke);
if from_live {
r["fromLive"] = json!(true);
@@ -432,7 +459,12 @@ impl LiveRetouch {
/// Everything the stroke has touched so far.
pub fn bounds(&self) -> Rect {
self.renderer.bounds().union(&self.tail)
let bounds = self.renderer.bounds().union(&self.tail);
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b))
} else {
bounds
}
}
/// Every point rendered so far, in order.
@@ -475,6 +507,9 @@ impl LiveRetouch {
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
self.points.extend_from_slice(&pts);
self.renderer.push(&pts);
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
return self.push_periodic();
}
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
let mut dmg = Rect::EMPTY;
if !old.is_empty() {
@@ -496,13 +531,36 @@ impl LiveRetouch {
Ok(dmg)
}
fn push_periodic(&mut self) -> Result<Rect> {
let mut dirty = self.renderer.take_dirty_rect().union(&self.tail);
let mut tail = self.renderer.tail_preview();
self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect());
dirty = dirty.union(&self.tail);
let LivePaint::Clone { which, map, cmd } = &self.paint else {
return Err(bad(&self.cmd, "periodic painting requires a clone source"));
};
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
periodic::Composite {
target: surf,
before: &self.pre_surf,
tile: self.pre.bounds(),
opacity: self.opacity,
selection: self.sel.as_ref(),
lock: self.lock,
mode: self.mode,
}
.draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd))
}
/// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`.
fn composite(&mut self, r: Rect, cov: &[f32]) -> Result<Rect> {
if r.is_empty() {
return Ok(r);
}
let paint = match &self.paint {
LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?,
LivePaint::Clone { which, map, cmd } => {
clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, *cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false), cmd)?
}
LivePaint::History(src) => Region::read(src, r),
LivePaint::Pattern { tile, place } => pattern_stamp::paint_region(tile, place, &self.pre_surf.format(), r),
};
@@ -562,7 +620,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
let (bounds, cov) = stroke_coverage(&stroke);
let (g, cov) = pad_coverage(bounds, &cov, 2);
let fmt = surf.format();
let src = clone_sample(pre, id, surf, which, g, &map, CMD)?;
let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?;
// The destination the texture is fitted to: the layer itself, or what the user sees when
// sampling several layers (so healing onto an empty layer matches the composite).
let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) };
@@ -1251,7 +1309,7 @@ pub fn specs() -> Vec<CommandSpec> {
menu: &[],
shortcut: None,
params: brush_params!(
r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal" → {"damage","offset","aligned","nextSource"}"#
r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal","wrap":bool=false (repeat source and stroke at document edges for Pattern Preview) → {"damage","offset","aligned","nextSource"}"#
),
enabled: has_pixel_layer,
run: clone_stamp,
+272
View File
@@ -0,0 +1,272 @@
//! Bounded reads from the repeating document tile used by Pattern Preview.
use super::*;
/// At most four disjoint rectangles in the document tile. Projecting the dirty area separately
/// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam.
pub(super) fn project(rect: Rect, tile: Rect) -> Vec<Rect> {
if rect.is_empty() || tile.is_empty() {
return Vec::new();
}
let axis = |a: i32, b: i32, lo: i32, hi: i32| {
let period = i64::from(hi) - i64::from(lo);
let length = i64::from(b) - i64::from(a);
if length >= period {
return vec![(lo, hi)];
}
let start = i64::from(lo) + (i64::from(a) - i64::from(lo)).rem_euclid(period);
let end = start + length;
if end <= i64::from(hi) { vec![(start as i32, end as i32)] } else { vec![(start as i32, hi), (lo, (i64::from(lo) + end - i64::from(hi)) as i32)] }
};
let xs = axis(rect.x0, rect.x1, tile.x0, tile.x1);
let ys = axis(rect.y0, rect.y1, tile.y0, tile.y1);
ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect()
}
/// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels.
/// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and
/// selection are applied once after all dabs, including repeated visits through other tiles.
pub(super) struct Composite<'a> {
pub target: &'a mut Surface,
pub before: &'a Surface,
pub tile: Rect,
pub opacity: f32,
pub selection: Option<&'a Surface>,
pub lock: bool,
pub mode: BlendMode,
}
impl Composite<'_> {
pub fn draw(&mut self, dirty: Rect, brush: &BrushSettings, points: &[StrokePoint], mut paint: impl FnMut(Rect) -> Result<Region>) -> Result<Rect> {
let ctx = photocraft_paint::BrushContext::new(brush);
let mut generator = photocraft_paint::dynamics::DabGenerator::new(brush, 1.0);
let (mut primary, mut dual) = (Vec::new(), Vec::new());
generator.push(points, &mut primary, &mut dual);
generator.finish(&mut primary, &mut dual);
let primary: Vec<_> = primary
.into_iter()
.map(|d| {
let r = ctx.dab_rect(&d, false);
(d, r)
})
.collect();
let dual: Vec<_> = dual
.into_iter()
.map(|d| {
let r = ctx.dab_rect(&d, true);
(d, r)
})
.collect();
let mut damage = Rect::EMPTY;
let mut work = 0_u64;
for rect in project(dirty, self.tile) {
let mut y = rect.y0;
while y < rect.y1 {
let y1 = y.saturating_add(64).min(rect.y1);
let mut x = rect.x0;
while x < rect.x1 {
let x1 = x.saturating_add(64).min(rect.x1);
let dst = Rect::new(x, y, x1, y1);
let mut stroke = PatchStroke::new(dst, self.target.channels());
let mut dual_stroke = PatchStroke::new(dst, 1);
let alpha = alpha_index(&self.target.format());
for (dab, bounds) in &primary {
if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) {
continue;
}
let footprint = self.fold_dab(&ctx, dab, *bounds, dst, false, &mut paint, &mut work)?;
stroke.accumulate(dab, &footprint, alpha);
}
for (dab, bounds) in &dual {
if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) {
continue;
}
let footprint = self.fold_dab(&ctx, dab, *bounds, dst, true, &mut paint, &mut work)?;
let mut dab = *dab;
dab.opacity = 1.0;
dual_stroke.accumulate(&dab, &footprint, None);
}
stroke.normalize(alpha);
for py in dst.y0..dst.y1 {
for px in dst.x0..dst.x1 {
let i = stroke.paint.index(px, py);
let depth = brush.wet_edges.then(|| stroke.depth.get(i).copied().unwrap_or(0.0));
let dual = brush.dual_brush.enabled.then(|| dual_stroke.coverage.get(i).copied().unwrap_or(0.0));
if let Some(c) = stroke.coverage.get_mut(i) {
*c = ctx.stroke_mask_wet(*c, depth, dual, px, py);
}
}
}
self.target.write_region(dst, &self.before.read_region(dst));
apply_coverage(self.target, dst, &stroke.coverage, self.opacity, self.selection, self.lock, &stroke.paint, self.mode);
damage = damage.union(&dst);
x = x1;
}
y = y1;
}
}
Ok(damage)
}
#[allow(clippy::too_many_arguments)]
fn fold_dab(
&self,
ctx: &photocraft_paint::BrushContext,
dab: &photocraft_paint::Dab,
bounds: Rect,
dst: Rect,
dual: bool,
paint: &mut impl FnMut(Rect) -> Result<Region>,
work: &mut u64,
) -> Result<PatchStroke> {
let mut result = PatchStroke::new(dst, if dual { 1 } else { self.target.channels() });
let (w, h) = (i64::from(self.tile.width()), i64::from(self.tile.height()));
let nx0 = (i64::from(bounds.x0) - i64::from(dst.x1)).div_euclid(w) + 1;
let nx1 = (i64::from(bounds.x1) - 1 - i64::from(dst.x0)).div_euclid(w);
let ny0 = (i64::from(bounds.y0) - i64::from(dst.y1)).div_euclid(h) + 1;
let ny1 = (i64::from(bounds.y1) - 1 - i64::from(dst.y0)).div_euclid(h);
let (mut values, mut depths) = (Vec::new(), Vec::new());
let wet = ctx.brush.wet_edges && !dual;
for ny in ny0..=ny1 {
for nx in nx0..=nx1 {
let (dx, dy) = (nx * w, ny * h);
let r = Rect::new(
(i64::from(dst.x0) + dx).max(i64::from(bounds.x0)) as i32,
(i64::from(dst.y0) + dy).max(i64::from(bounds.y0)) as i32,
(i64::from(dst.x1) + dx).min(i64::from(bounds.x1)) as i32,
(i64::from(dst.y1) + dy).min(i64::from(bounds.y1)) as i32,
);
*work = work.saturating_add(r.width() as u64 * r.height() as u64);
if *work > 64 * 1024 * 1024 {
return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget"));
}
ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths));
let mut sampled = None;
for sy in r.y0..r.y1 {
for sx in r.x0..r.x1 {
let source_index = ((sy - r.y0) as usize) * r.width() as usize + (sx - r.x0) as usize;
let value = values.get(source_index).copied().unwrap_or(0.0);
if value <= 0.0 {
continue;
}
let (tx, ty) = ((i64::from(sx) - dx) as i32, (i64::from(sy) - dy) as i32);
let i = result.paint.index(tx, ty);
if let Some(depth) = result.depth.get_mut(i) {
*depth = depth.max(depths.get(source_index).copied().unwrap_or(0.0));
}
let Some(previous) = result.coverage.get_mut(i) else { continue };
if value <= *previous {
continue;
}
if !dual {
if sampled.is_none() {
sampled = Some(paint(r)?);
}
if let Some(source) = &sampled {
result.paint.px_mut(tx, ty).copy_from_slice(source.px(sx, sy));
}
}
*previous = value;
}
}
}
}
Ok(result)
}
}
struct PatchStroke {
coverage: Vec<f32>,
depth: Vec<f32>,
// Native colours weighted by coverage and source alpha; alpha weighted by coverage.
paint: Region,
}
impl PatchStroke {
fn new(rect: Rect, channels: usize) -> Self {
let len = rect.width() as usize * rect.height() as usize;
Self { coverage: vec![0.0; len], depth: vec![0.0; len], paint: Region::new(rect, channels) }
}
fn accumulate(&mut self, dab: &photocraft_paint::Dab, footprint: &PatchStroke, alpha: Option<usize>) {
let rect = self.paint.rect;
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = self.paint.index(x, y);
let Some(c) = self.coverage.get_mut(i) else { continue };
let value = footprint.coverage.get(i).copied().unwrap_or(0.0);
if value <= 0.0 {
continue;
}
if let Some(depth) = self.depth.get_mut(i) {
*depth = depth.max(footprint.depth.get(i).copied().unwrap_or(0.0));
}
if dab.opacity <= *c {
continue;
}
let next = *c + value * (dab.opacity - *c);
let weight = next - *c;
let source = footprint.paint.px(x, y);
let sa = alpha.and_then(|a| source.get(a)).copied().unwrap_or(1.0);
for (channel, (dst, src)) in self.paint.px_mut(x, y).iter_mut().zip(source).enumerate() {
*dst += if Some(channel) == alpha { weight * sa } else { weight * sa * src };
}
*c = next;
}
}
}
fn normalize(&mut self, alpha: Option<usize>) {
let rect = self.paint.rect;
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = self.paint.index(x, y);
let coverage = self.coverage.get(i).copied().unwrap_or(0.0);
let px = self.paint.px_mut(x, y);
let weight = alpha.and_then(|a| px.get(a)).copied().unwrap_or(coverage);
for (channel, value) in px.iter_mut().enumerate() {
let denominator = if Some(channel) == alpha { coverage } else { weight };
*value = if denominator > 0.0 { *value / denominator } else { 0.0 };
}
}
}
}
}
pub(super) fn sample(pre: &Document, id: Option<LayerId>, surf: &Surface, which: SampleLayers, rect: Rect, cmd: &str) -> Result<Region> {
let tile = pre.bounds();
if tile.is_empty() {
return Err(bad(cmd, "cannot repeat an empty document"));
}
if rect.is_empty() {
return Ok(Region::new(Rect::EMPTY, surf.channels()));
}
let (w, h, n) = (rect.width() as usize, rect.height() as usize, surf.channels());
let len = w
.checked_mul(h)
.and_then(|v| v.checked_mul(n))
.filter(|&v| v <= 16 * 1024 * 1024)
.ok_or_else(|| bad(cmd, "periodic source window exceeds its 64 MiB budget"))?;
let mut out = Region { rect, ch: n, data: vec![0.0; len] };
// Split at tile seams. Read only the required rectangles, including the bilinear apron,
// rather than allocating or compositing the entire document for a small clone dab.
let mut dy = 0;
while dy < h {
let sy = (i64::from(rect.y0) + dy as i64 - i64::from(tile.y0)).rem_euclid(i64::from(tile.height())) as i32 + tile.y0;
let rows = (h - dy).min((tile.y1 - sy) as usize);
let mut dx = 0;
while dx < w {
let sx = (i64::from(rect.x0) + dx as i64 - i64::from(tile.x0)).rem_euclid(i64::from(tile.width())) as i32 + tile.x0;
let cols = (w - dx).min((tile.x1 - sx) as usize);
let part = super::sample(pre, id, surf, which, Rect::new(sx, sy, sx + cols as i32, sy + rows as i32));
for (dst, src) in out.data.chunks_exact_mut(w * n).skip(dy).take(rows).zip(part.data.chunks_exact(cols * n)) {
if let Some(dst) = dst.get_mut(dx * n..(dx + cols) * n) {
dst.copy_from_slice(src);
}
}
dx += cols;
}
dy += rows;
}
Ok(out)
}
+263
View File
@@ -3,6 +3,171 @@ use photocraft_color::ColorMode;
const DEPTHS: [u64; 3] = [8, 16, 32];
#[test]
fn periodic_clone_large_footprint_applies_opacity_once_and_respects_destination_masks() {
for depth in DEPTHS {
for lock in [false, true] {
let mut s = session(8, 8, depth, "rgb");
paint_layer(&mut s, |x, _| {
if x < 4 {
[1.0, 0.0, 0.0, 1.0]
} else if x == 7 {
[0.0; 4]
} else {
[1.0; 4]
}
});
s.edit("lock", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = lock;
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x":5,"y":0,"width":3,"height":8})).unwrap();
s.execute("paint.cloneStamp", json!({"points":[[6,4]],"offset":[4,0],"size":32,"hardness":100,"opacity":50,"wrap":true})).unwrap();
assert_eq!(rgba(&s, 4, 4), [1.0; 4], "selection is evaluated at the wrapped destination");
let opaque = rgba(&s, 6, 4);
assert!((opaque[1] - 0.5).abs() <= tol(depth), "overlapping tile copies must not multiply opacity: {opaque:?}");
let transparent = rgba(&s, 7, 4);
if lock {
assert_eq!(transparent[3], 0.0, "locked transparent destination stays transparent");
} else {
assert!((transparent[3] - 0.5).abs() <= tol(depth), "unlocked transparent destination receives one stroke: {transparent:?}");
assert_eq!(transparent[0], 1.0);
}
}
}
}
#[test]
fn periodic_clone_selection_uses_native_cmyk_pixels_across_seam() {
let mut s = session(64, 32, 8, "cmyk");
paint_layer(&mut s, |x, _| if x < 32 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 1.0, 0.0, 1.0] });
s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":32})).unwrap();
s.execute("paint.cloneStamp", json!({"points":[[63,16]],"offset":[-23,0],"size":16,"hardness":100,"wrap":true})).unwrap();
let (inside, outside) = (rgba(&s, 1, 16), rgba(&s, 5, 16));
assert!(inside[0] > 0.8 && inside[2] < 0.3, "selected wrapped destination becomes yellow: {inside:?}");
assert!(outside[2] > outside[0] + 0.2, "unselected neighbour remains blue: {outside:?}");
}
#[test]
fn periodic_clone_projection_is_disjoint_and_matches_pixel_modulo() {
use std::collections::HashSet;
let tile = Rect::new(0, 0, 8, 6);
for rect in [Rect::new(-2, -3, 3, 2), Rect::new(-10, -9, 19, 14), Rect::new(7, 5, 10, 8), Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2)] {
let expected: HashSet<_> = (rect.y0..rect.y1).flat_map(|y| (rect.x0..rect.x1).map(move |x| (x.rem_euclid(8), y.rem_euclid(6)))).collect();
let mut actual = HashSet::new();
for r in periodic::project(rect, tile) {
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
assert!(actual.insert((x, y)), "projected rectangles overlap");
}
}
}
assert_eq!(actual, expected);
}
}
#[test]
fn periodic_clone_preview_interpolates_across_transformed_source_seam() {
use crate::presets::clone_source::Mapping;
for depth in DEPTHS {
let mut s = session(32, 24, depth, "rgb");
paint_layer(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] });
let before = s.active().unwrap().doc.clone();
// A rotated mapping samples halfway between the last and first columns.
let map = Mapping { source: (31.5, 10.0), anchor: (0.0, 0.0), m: [0.0, 1.0, -1.0, 0.0] };
let r = Rect::new(0, 0, 1, 1);
let wrapped = clone_preview_periodic(&s, r, &map, "current", true).unwrap();
assert_eq!(wrapped.px, [[0.5, 0.0, 0.5, 1.0]]);
let ordinary = clone_preview(&s, r, &map, "current").unwrap();
assert_eq!(ordinary.px, [[0.0, 0.0, 1.0, 0.5]]);
assert_eq!(s.active().unwrap().doc, before);
}
}
#[test]
fn periodic_clone_destination_wraps_edges_and_undoes_at_every_depth() {
for depth in DEPTHS {
for wrap in [false, true] {
let mut s = session(100, 60, depth, "rgb");
paint_layer(&mut s, |x, y| if x < 24 && y < 24 { [1.0, 0.0, 0.0, 1.0] } else { [1.0; 4] });
let before = s.active().unwrap().doc.clone();
s.execute("paint.cloneStamp", json!({"points":[[98,30]],"source":[10,10],"size":16,"hardness":100,"opacity":50,"wrap":wrap})).unwrap();
for x in [98, 1] {
let expected = if x == 98 || wrap { [1.0, 0.5, 0.5, 1.0] } else { [1.0; 4] };
let actual = rgba(&s, x, 30);
for (a, b) in actual.into_iter().zip(expected) {
assert!((a - b).abs() <= tol(depth), "depth={depth} wrap={wrap} x={x}: {actual:?}");
}
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before, "one undo restores the whole wrapped stroke");
}
}
}
#[test]
fn periodic_clone_live_matches_commit_through_a_corner() {
for depth in DEPTHS {
let mut s = session(32, 24, depth, "rgb");
paint_layer(&mut s, texture);
let before = s.active().unwrap().doc.clone();
let mut params = json!({"points":[[30,22]],"source":[10,10],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true});
let mut live = LiveRetouch::begin(&s, "paint.cloneStamp", &params).unwrap();
let mut points = vec![json!([30, 22])];
for next in [None, Some([34.0, 26.0]), Some([38.0, 28.0]), Some([30.0, 22.0])] {
if let Some([x, y]) = next {
live.push(&[StrokePoint::new(x, y, 1.0)]).unwrap();
points.push(json!([x, y]));
}
params["points"] = json!(points);
let mut committed = Session::new();
committed.add_document((*before).clone(), None);
committed.execute("paint.cloneStamp", params.clone()).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds());
let expected = committed.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds());
assert_eq!(actual, expected, "live versus committed pixels at depth {depth}, points {points:?}");
}
}
}
#[test]
fn periodic_clone_source_repeats_both_axes_at_every_depth() {
for depth in DEPTHS {
let mut s = session(6, 4, depth, "rgb");
paint_layer(&mut s, |x, y| [x as f32 / 6.0, y as f32 / 4.0, 0.25, 1.0]);
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
let r = Rect::new(-8, -6, 15, 11);
for which in [SampleLayers::Current, SampleLayers::CurrentAndBelow, SampleLayers::All] {
let result = periodic::sample(&st.doc, st.active_layer, surf, which, r, "test").unwrap();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let expected = surf.rgba(x.rem_euclid(6), y.rem_euclid(4));
let actual = to_rgba(&surf.format(), result.px(x, y));
for (a, b) in actual.into_iter().zip(expected) {
assert!((a - b).abs() <= tol(depth), "{depth}-bit ({x},{y}): {actual:?} != {expected:?}");
}
}
}
}
}
}
#[test]
fn periodic_clone_source_bounds_allocations_and_accepts_far_coordinates() {
let s = session(6, 4, 8, "rgb");
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
assert!(periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, Rect::new(0, 0, 100_000, 100_000), "test").is_err());
for r in [Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2), Rect::new(i32::MAX - 3, i32::MAX - 2, i32::MAX, i32::MAX)] {
let result = periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, r, "test").unwrap();
assert_eq!(result.data.len(), 3 * 2 * surf.channels());
assert!(result.data.iter().all(|v| *v == 1.0));
}
}
#[test]
fn proximity_match_reads_original_pixels_for_overlapping_offsets() {
// Shifting source one pixel to the left overlaps the destination in a
@@ -1860,3 +2025,101 @@ fn a_live_retouch_stroke_ignores_tilt_and_times() {
let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b);
assert_eq!(read(&plain), read(&pen));
}
#[test]
fn periodic_clone_low_flow_revisits_accumulate_across_tile_aliases() {
// Independent oracle: paint the same continuous stroke over two physical copies of a
// tile, then combine the two coverage values using the brush's flow accumulation rule.
let setup = |width| {
let mut s = Session::new();
s.execute("file.new", json!({"width":width,"height":24,"depth":32})).unwrap();
for x in (8..width).step_by(32) {
s.execute("paint.pencil", json!({"points":[[x,12]],"size":16,"color":"#ff0000"})).unwrap();
}
s
};
let params = |wrap| json!({"points":[[22,12],[54,12]],"offset":[-18,0],"size":2,"hardness":100,"flow":20,"opacity":100,"wrap":wrap});
let green = |s: &Session, x| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, 12)[1]
};
let mut physical = setup(64);
physical.execute("paint.cloneStamp", params(false)).unwrap();
let (a, b) = (green(&physical, 22), green(&physical, 54));
assert!(a > 0.0 && a < 1.0 && b > 0.0 && b < 1.0, "oracle needs two partial visits: {a}, {b}");
let expected = a * b;
let mut periodic = setup(32);
periodic.execute("paint.cloneStamp", params(true)).unwrap();
let actual = green(&periodic, 22);
assert!((actual - expected).abs() < 0.0001, "repeated visits should build flow: actual green={actual}, expected={expected}, physical copies={a},{b}");
}
#[test]
fn periodic_clone_live_offer_commits_and_undoes_the_same_pixels_as_replay() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
s.execute("paint.pencil", json!({"points":[[8,8]],"size":12,"color":"#d03060"})).unwrap();
let before = s.active().unwrap().doc.clone();
let id = s.active().unwrap().active_layer.unwrap();
let params = json!({"points":[[30,22],[34,26],[38,28],[30,22]],"source":[8,8],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true});
let live = LiveRetouch::begin(&s, "paint.cloneStamp", &params).unwrap();
let shown = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds());
s.offer_live_retouch(live).unwrap();
let result = s.execute("paint.cloneStamp", params.clone()).unwrap();
assert_eq!(result["fromLive"], true);
let read = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds());
assert_eq!(read(&s), shown, "offered preview at depth {depth}");
let mut replay = Session::new();
replay.add_document((*before).clone(), None);
replay.execute("paint.cloneStamp", params).unwrap();
assert_eq!(read(&s), read(&replay), "command replay at depth {depth}");
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before, "one undo at depth {depth}");
}
}
#[test]
fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() {
let cases = [
json!({"wetEdges":true}),
json!({"dualBrush":{"enabled":true,"size":7,"hardness":0.5,"spacing":0.2}}),
json!({"texture":{"enabled":true,"eachTip":false,"depth":0.6}}),
json!({"pressureOpacity":true,"pressureSize":false}),
json!({"wetEdges":true,"dualBrush":{"enabled":true,"size":7},"pressureOpacity":true}),
];
for depth in DEPTHS {
for brush in &cases {
let setup = || {
let mut s = session(96, 64, depth, "rgb");
paint_layer(&mut s, |x, y| {
let mut px = texture(x, y);
px[3] = if x < 40 { 0.4 } else { 0.75 };
px
});
s.execute("tools.setBrush", json!({"brush":brush})).unwrap();
s.execute("select.rect", json!({"x":48,"y":16,"width":36,"height":36,"feather":2})).unwrap();
s
};
let mut ordinary = setup();
let mut periodic = setup();
let mut p = json!({"points":[[55,30,0.4],[65,34,0.8],[72,30,0.3]],"source":[20,20],"size":12,"hardness":65,"flow":35,"opacity":60});
ordinary.execute("paint.cloneStamp", p.clone()).unwrap();
p["wrap"] = json!(true);
periodic.execute("paint.cloneStamp", p).unwrap();
let read = |s: &Session| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
};
let (a, b) = (read(&ordinary), read(&periodic));
let tolerance = if depth == 8 {
1.1 / 255.0
} else if depth == 16 {
1.1 / 65535.0
} else {
0.00001
};
let error = a.iter().zip(&b).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max);
assert!(error <= tolerance, "depth={depth}, brush={brush}, max native-channel error={error}");
}
}
}
+74 -2
View File
@@ -5,6 +5,7 @@
use photocraft_algo::selection::{self as sel, SelectionMode};
use photocraft_doc::Document;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
@@ -603,11 +604,51 @@ pub(crate) fn polygon_selection(s: &mut Session, cmd: &str, label: &str, pts: &[
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "feather must be a number in 0..1000".into() });
}
let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds();
let mut mask = sel::polygon(pts, area, b(p, "antiAlias", true));
if feather > 0.0 {
// The blur spreads past the polygon and isn't bit-identical over a smaller window.
let mut mask = sel::polygon(pts, area, b(p, "antiAlias", true));
mask = sel::feather(&mask, area.width() as usize, area.height() as usize, feather);
return set_selection(s, label, area, mask, mode(p));
}
set_selection(s, label, area, mask, mode(p))
// Only the rows the polygon spans (#4532): rows outside it are empty, and full-width rows
// keep `polygon`'s coverage bit-identical (its x arithmetic is relative to the area's left
// edge; its y samples are whole rows plus the same sub-row fractions).
let band = polygon_rows(pts, area);
let mask = sel::polygon(pts, band, b(p, "antiAlias", true));
let m = mode(p);
let selected = s.edit(label, |doc, _| {
doc.selection = combine_band(doc.selection.as_ref(), &mask, band, m);
Ok(doc.selection.is_some())
})?;
Ok(json!({ "selected": selected }))
}
/// The full-width rows of `area` that points `pts` span (empty when none are finite).
fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect {
let ys = pts.iter().map(|p| p.1).filter(|y| y.is_finite());
let (lo, hi) = ys.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)));
if lo > hi {
return Rect::EMPTY;
}
let clamp = |v: f32| v.clamp(area.y0 as f32, area.y1 as f32) as i32;
let (y0, y1) = (clamp(lo.floor()), clamp(hi.ceil() + 1.0));
if y0 >= y1 { Rect::EMPTY } else { Rect::new(area.x0, y0, area.x1, y1) }
}
/// [`sel::combine`] of a mask covering only `band` (zero elsewhere), giving exactly what the
/// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and
/// Subtract keep the old selection there.
fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option<Surface> {
let inside = sel::combine(old, mask, band, m);
let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
if band.is_empty() {
return Some(old.clone());
}
let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band));
let mut s = old.clone();
s.write_region(band, &values);
s.prune();
(s.tile_count() > 0).then_some(s)
}
macro_rules! spec {
@@ -1270,4 +1311,35 @@ mod tests {
}
}
}
/// The lasso over the polygon's rows gives exactly the whole-canvas result (#4532): random
/// polygons, partly off the canvas, with and without anti-aliasing, in every mode over an
/// existing selection.
#[test]
fn lasso_over_its_rows_matches_the_whole_canvas() {
let mut seed = 0x2545_f491_u32;
let mut rnd = |lo: f32, hi: f32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
lo + (seed % 10_000) as f32 / 10_000.0 * (hi - lo)
};
for case in 0..24 {
let mut s = Session::new();
s.execute("file.new", json!({"width": 300, "height": 520})).unwrap();
s.execute("select.rect", json!({"x": 40, "y": 60, "width": 200, "height": 300})).unwrap();
let n = 3 + case % 7;
let pts: Vec<(f32, f32)> = (0..n).map(|_| (rnd(-30.0, 330.0), rnd(100.0, 420.0))).collect();
let aa = case % 2 == 0;
let m = ["replace", "add", "subtract", "intersect"][case % 4];
let doc = s.active().unwrap().doc.clone();
let area = doc.bounds();
let want = sel::combine(doc.selection.as_ref(), &sel::polygon(&pts, area, aa), area, SelectionMode::parse(m));
let points: Vec<[f32; 2]> = pts.iter().map(|p| [p.0, p.1]).collect();
s.execute("select.lasso", json!({"points": points, "antiAlias": aa, "mode": m})).unwrap();
let got = s.active().unwrap().doc.selection.clone();
let read = |x: &Option<Surface>| x.as_ref().map(|s| s.read_region(area));
assert_eq!(read(&got), read(&want), "case {case}: {m}, anti-alias {aa}, {pts:?}");
}
}
}
+26 -3
View File
@@ -862,12 +862,28 @@ pub fn contents_of(doc: &Document, id: LayerId) -> Result<(String, Arc<Vec<u8>>)
source_bytes(&doc.metadata, &smart(doc, id)?.source).ok_or_else(|| other("the smart object's contents are unavailable"))
}
/// Writes the smart object's file as it is. To a path with another extension (a `.png` for
/// `.psb` or `.pcraft` contents) the contents are opened and exported in that format instead.
fn export_contents(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_param("layer.smartObjects.exportContents", p)?;
let id = layer_param(s, p)?;
let (name, bytes) = contents_of(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
crate::file_cmds::write_file(path, &bytes)?;
Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()}))
let ext = |f: &str| {
let e = std::path::Path::new(f).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
e.map(|e| match e.as_str() {
"jpeg" => "jpg".to_string(),
"tiff" => "tif".to_string(),
_ => e,
})
};
if ext(path).is_none() || ext(path) == ext(&name) {
crate::file_cmds::write_file(path, &bytes)?;
return Ok(json!({"path": path, "fileName": name, "bytes": bytes.len(), "converted": false}));
}
let contents = decode_source(&name, &bytes)?;
let r = photocraft_io::export(&contents, path, &Default::default()).map_err(|e| other(format!("can't export the contents as {}: {e}", base_name(path))))?;
crate::file_cmds::write_file(path, &r.bytes)?;
Ok(json!({"path": path, "fileName": name, "bytes": r.bytes.len(), "converted": true, "warnings": r.warnings}))
}
fn edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1216,7 +1232,14 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"updated": commit_child(s, i)?}))
}),
spec!("layer.smartObjects.replaceContents", "Replace Contents…", SO, r##"{"layer":id?,"path":str}"##, has_smart, replace_contents),
spec!("layer.smartObjects.exportContents", "Export Contents…", SO, r##"{"layer":id?,"path":str}"##, has_smart, export_contents),
spec!(
"layer.smartObjects.exportContents",
"Export Contents…",
SO,
r##"{"layer":id?,"path":str (another extension than the contents' own exports them in that format)}"##,
has_smart,
export_contents
),
spec!("layer.smartObjects.relinkToFile", "Relink to File…", SO, r##"{"layer":id?,"path":str}"##, has_smart, |s, p| {
let path = path_param("layer.smartObjects.relinkToFile", p)?.to_string();
set_source(s, p, "Relink to File", false, |_, _| Ok(SmartSource::Linked { path }))
+15 -1
View File
@@ -529,8 +529,18 @@ fn export_replace_and_linked_contents() {
paint(&mut s);
convert(&mut s);
let out = dir.join("contents.pcraft");
s.execute("layer.smartObjects.exportContents", json!({"path": out.to_str().unwrap()})).unwrap();
let r = s.execute("layer.smartObjects.exportContents", json!({"path": out.to_str().unwrap()})).unwrap();
assert!(photocraft_format::is_pcraft(&std::fs::read(&out).unwrap()));
assert_eq!(r["converted"], false);
// Another extension exports the contents in that format.
for (ext, magic) in [("png", &b"\x89PNG"[..]), ("jpeg", &b"\xFF\xD8\xFF"[..]), ("psd", &b"8BPS"[..])] {
let other = dir.join(format!("contents.{ext}"));
let r = s.execute("layer.smartObjects.exportContents", json!({"path": other.to_str().unwrap()})).unwrap();
assert_eq!(r["converted"], true, "{ext}");
assert!(std::fs::read(&other).unwrap().starts_with(magic), "{ext}");
}
let bad = dir.join("contents.nope");
assert!(s.execute("layer.smartObjects.exportContents", json!({"path": bad.to_str().unwrap()})).is_err());
// Replace with a flat PNG.
let png_doc = Document::with_background(
"p",
@@ -1052,6 +1062,10 @@ fn inspect_reports_the_smart_source() {
let source = |s: &Session| crate::inspect::layer(s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap())["smartSource"].clone();
let front = source(&s);
assert_eq!(front, json!({"kind": "linked", "path": "uuid-1"}));
// The document's info knows the file is embedded in the PSD (a `liFD` item) and says so,
// with its file name; the uuid stays its key.
let info = crate::inspect::document(s.active().unwrap());
assert_eq!(info["layers"][0]["smartSource"], json!({"kind": "embedded", "fileName": "in.png", "uuid": "uuid-1"}));
// A duplicate shares the file; New Smart Object via Copy gets its own.
s.execute("layer.duplicate", json!({})).unwrap();
assert_eq!(source(&s), front, "a duplicate shares its source");
+29 -2
View File
@@ -397,7 +397,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
}
let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| {
let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface);
let sel = doc.selection.as_ref().or(mask_surf)?;
let target_mask = p.get("target").and_then(Value::as_str) == Some("mask");
let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?;
let brush = matting::EdgeBrush::new(&strokes, doc.bounds());
matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), &params, brush.as_ref())
})?;
@@ -416,7 +417,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Select and Mask", |doc, _| {
let surface =
region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8));
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() });
let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all);
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing });
doc.selection = None;
Ok(())
})?;
@@ -821,6 +823,31 @@ mod tests {
assert!(s.active().unwrap().doc.layers[0].mask.is_none());
assert!(s.active().unwrap().doc.selection.is_some());
// Existing mask settings (density, feather, linked) are preserved
s.edit("set mask", |doc, _| {
doc.layers[0].mask = Some(photocraft_doc::LayerMask {
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8),
density: 0.75,
feather: 3.5,
linked: false,
enabled: true,
});
Ok(())
})
.unwrap();
let r = s.execute("select.refineEdge", json!({"radius": 8, "output": "layerMask"})).unwrap();
let l = s.active().unwrap().doc.layer(photocraft_doc::LayerId(r["layer"].as_u64().unwrap())).unwrap();
let m = l.mask.as_ref().unwrap();
assert_eq!(m.density, 0.75);
assert_eq!(m.feather, 3.5);
assert!(!m.linked);
s.execute("edit.undo", json!({})).unwrap();
s.edit("clear mask", |doc, _| {
doc.layers[0].mask = None;
Ok(())
})
.unwrap();
// New layer: pixels with the refined alpha; the source is hidden.
let r = s.execute("select.refineEdge", json!({"radius": 8, "feather": 1, "output": "newLayer"})).unwrap();
let d = s.active().unwrap();
+11 -1
View File
@@ -600,8 +600,18 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
// Names stay unique across the library (agents address swatches by name).
let mut next: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut taken: std::collections::HashSet<String> = lib.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect();
// Group names are numbered from 1 as in `unique_group` ("Grays 1"), and the numbering
// resumes per base name.
let mut group_next: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut group_taken: std::collections::HashSet<String> = lib.iter().map(|g| g.name.clone()).collect();
for mut g in groups {
g.name = unique_group(&lib, &g.name);
if group_taken.contains(&g.name) {
let from = group_next.get(&g.name).copied().unwrap_or(1);
let (i, n) = (from..).map(|i| (i, format!("{} {i}", g.name))).find(|(_, n)| !group_taken.contains(n)).unwrap_or((from, g.name.clone()));
group_next.insert(g.name.clone(), i + 1);
g.name = n;
}
group_taken.insert(g.name.clone());
for sw in &mut g.items {
if taken.contains(&sw.name) {
// Resume numbering per base name, so a file of identical names stays linear.
+44
View File
@@ -291,6 +291,50 @@ fn library_limits() {
assert_eq!(g[0].name.chars().count(), MAX_NAME);
}
/// Groups without a name take the import's group name. The import used to number each repeated
/// group name by searching the library once for every number it tried, so importing about 10,000
/// unnamed groups took minutes. The test fails when the import takes more than 20 seconds.
#[test]
fn unnamed_groups_are_numbered_in_linear_time() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut s = Session::new();
s.execute("swatches.newGroup", json!({"name": "Ink 2"})).unwrap();
let n = MAX_GROUPS - s.presets.swatches.len();
let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process };
let mut entries = vec![AseEntry::Group { name: String::new(), swatches: Vec::new() }; n];
entries[0] = AseEntry::Group { name: String::new(), swatches: vec![paper] };
let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap());
let r = s.execute("swatches.import", json!({"data": file, "group": "Ink"})).unwrap();
assert_eq!(s.presets.swatches.len(), MAX_GROUPS);
let names: Vec<String> = r["groups"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let _ = tx.send(names);
});
let names = match rx.recv_timeout(std::time::Duration::from_secs(20)) {
Ok(names) => names,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("importing the groups did not finish"),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the import thread panicked"),
};
// Numbering starts at 1 and skips the number the library already uses.
assert_eq!(names[..4], ["Ink", "Ink 1", "Ink 3", "Ink 4"]);
assert_eq!(names.last(), Some(&format!("Ink {}", names.len())));
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
}
#[test]
fn repeated_group_names_take_the_first_free_number() {
let mut s = Session::new();
for name in ["Brand", "Brand 2"] {
s.execute("swatches.newGroup", json!({"name": name})).unwrap();
}
let paper = AseSwatch { name: "Paper".into(), color: AseColor::Gray(1.0), kind: AseKind::Process };
let entries = ["Brand", "Brand", "Brand", "Brand 1"].into_iter().map(|name| AseEntry::Group { name: name.into(), swatches: vec![paper.clone()] }).collect();
let file = b64_encode(&photocraft_psd::ase::write(&AseFile { entries }).unwrap());
let r = s.execute("swatches.import", json!({"data": file})).unwrap();
// The file's "Brand 1" comes after the first "Brand" took that name.
assert_eq!(r["groups"], json!(["Brand 1", "Brand 3", "Brand 4", "Brand 1 1"]));
}
/// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN
/// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset.
#[test]
+24 -11
View File
@@ -509,20 +509,30 @@ fn update_all(s: &mut Session) -> Result<Value> {
Ok(json!({"updated": n}))
}
fn installed_families() -> Vec<String> {
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.families().into_iter().map(|f| f.to_lowercase()).collect()
fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_doc::text::CharStyle) -> bool {
if style.font_family.is_empty() || fonts.has_family(&style.font_family) {
return false;
}
// Match layout's choice: an exact PostScript face wins over the import-time family guess;
// a heuristic family is usable only when the stored family is unavailable.
if let Some(ps) = &style.postscript_name {
let resolved = fonts.resolve_postscript(ps);
if resolved.exact || fonts.has_family(&resolved.family) {
return false;
}
}
true
}
/// Font families used by type layers that the font database cannot supply.
pub fn missing_fonts(doc: &Document) -> Vec<String> {
let have = installed_families();
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<String> = Vec::new();
for (_, _, l) in doc.walk() {
if let LayerContent::Text(t) = &l.content {
for r in t.char_runs() {
let f = r.style.font_family;
if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) {
out.push(f);
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
}
}
@@ -532,12 +542,11 @@ pub fn missing_fonts(doc: &Document) -> Vec<String> {
/// Font families used by a type layer that the font database cannot supply.
pub fn missing_fonts_in_layer(t: &TextLayer) -> Vec<String> {
let have = installed_families();
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<String> = Vec::new();
for r in t.char_runs() {
let f = r.style.font_family;
if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) {
out.push(f);
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
}
out
@@ -580,7 +589,11 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
let runs = t.char_runs();
let mut at = 0;
for r in runs {
if let Some(to) = pick(&r.style.font_family) {
let missing_run = {
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
style_font_missing(&mut engine.fonts, &r.style)
};
if missing_run && let Some(to) = pick(&r.style.font_family) {
style_range(t, at, at + r.len, &|st| {
st.font_family = to.clone();
st.postscript_name = None;
@@ -327,3 +327,74 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() {
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
/// A type layer stored with a family label that is not installed, as a PSD keeps the family it
/// guessed from a PostScript name whose face was absent when the file was opened.
fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) {
let mut p = json!({"x": 20, "y": 70, "text": text, "font": "Imported Wrong Family"});
if let Some(ps) = ps {
p["postscriptName"] = json!(ps);
}
let r = s.execute("type.create", p).unwrap();
(LayerId(r["layer"].as_u64().unwrap()), r)
}
#[test]
fn installed_postscript_face_is_not_missing_or_replaced() {
// Opening a PSD corrects the label when the face is installed; a run set by command, or one
// reopened from .pcraft after its face was installed, keeps it.
let mut s = session(8);
let (id, created) = guessed_family_layer(&mut s, "test", Some("Inter-SemiBold"));
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
let reopened = photocraft_io::import("reopened.pcraft", &bytes).unwrap().document;
let LayerContent::Text(t) = &reopened.layer(id).unwrap().content else { panic!("text") };
assert_eq!(t.char_runs()[0].style.font_family, "Imported Wrong Family", ".pcraft open keeps the stored label");
let on_open = missing_font_warnings(&reopened);
s.add_document(reopened, None);
let replaced = s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"].clone();
assert_eq!((created.get("warnings"), on_open, replaced), (None, Vec::<String>::new(), json!(0)));
}
#[test]
fn replacing_missing_family_preserves_a_resolved_run_with_the_same_label() {
let mut s = session(8);
let (good, _) = guessed_family_layer(&mut s, "good", Some("Inter-SemiBold"));
let (bad, _) = guessed_family_layer(&mut s, "bad", None);
assert_eq!(s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"], 1);
assert_eq!(text(&s, good).char_runs()[0].style.postscript_name.as_deref(), Some("Inter-SemiBold"));
assert_eq!(text(&s, bad).char_runs()[0].style.font_family, "Inter");
}
/// `source` renamed in memory to `family` with the PostScript name `ps`; no font file is added.
fn renamed_face(source: &[u8], family: &str, ps: &str) -> Vec<u8> {
let mut bytes = source.to_vec();
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
let record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..*r + 4] == b"name").unwrap();
let names = [(1u16, family), (2, "Regular"), (6, ps), (16, family), (17, "Regular")];
let mut table: Vec<u8> = [0u16, names.len() as u16, (6 + names.len() * 12) as u16].iter().flat_map(|v| v.to_be_bytes()).collect();
let mut strings = Vec::new();
for (id, value) in names {
let encoded: Vec<u8> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] {
table.extend(n.to_be_bytes());
}
strings.extend(encoded);
}
table.extend(strings);
let offset = bytes.len() as u32;
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
bytes.extend(table);
bytes
}
#[test]
fn exact_postscript_face_outside_the_guessed_family_is_not_missing() {
let mut fonts = photocraft_text::FontDb::new();
fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Exact Only Test", "UnrelatedPS30"));
let style =
|ps: &str| photocraft_doc::text::CharStyle { font_family: "Imported Wrong Family".into(), postscript_name: Some(ps.into()), ..Default::default() };
// Neither the stored family nor the family guessed from the name is installed: only the exact face.
assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family));
assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true));
}
+2
View File
@@ -230,6 +230,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
layers: d.layers.iter().map(|l| layer_m(l, sink)).collect(),
channels: d.channels.iter().map(|c| channel_m(c, sink)).collect(),
guides: d.guides.clone(),
ruler_origin: d.ruler_origin,
selection: d.selection.as_ref().map(|s| surface_m(s, sink)),
metadata: MetadataM {
xmp: d.metadata.xmp.clone(),
@@ -628,6 +629,7 @@ impl Loader<'_> {
layers,
channels,
guides: m.guides.clone(),
ruler_origin: m.ruler_origin,
selection: self.opt_surface(&m.selection)?,
metadata: md,
global_light: m.global_light,
+3
View File
@@ -46,6 +46,9 @@ pub struct DocM {
pub layers: Vec<LayerM>,
pub channels: Vec<ChannelM>,
pub guides: Guides,
/// The rulers' zero point in document pixels (absent in older files: the top-left).
#[serde(default)]
pub ruler_origin: [f64; 2],
pub selection: Option<SurfaceM>,
pub metadata: MetadataM,
pub global_light: GlobalLight,
+1
View File
@@ -230,6 +230,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
});
d.guides =
Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] };
d.ruler_origin = [12.5, -4.25];
let mut sel = Surface::new(PixelFormat::GRAY8);
scribble(&mut sel, 14, false);
d.selection = Some(sel);
+51 -2
View File
@@ -434,7 +434,15 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
// composite_with_effects' stroke bands (maps 0 outside, 1 inside)
let (in_w, out_w) = stroke_widths(st);
if out_w > 0.0 {
let d = b.field(if vector_shape { FieldKind::StrokeOutsideVector } else { FieldKind::StrokeOutside }, out_w);
// Narrow strokes keep the fitted chamfer, wide ones the exact transform (#3646);
// the CPU picks the same field by the same width (`stroke_outside_exact`).
let kind = match (vector_shape, photocraft_compose::effects::stroke_outside_exact(out_w)) {
(true, true) => FieldKind::StrokeOutsideVector,
(true, false) => FieldKind::StrokeOutsideVectorChamfer,
(false, true) => FieldKind::StrokeOutsideExact,
(false, false) => FieldKind::StrokeOutside,
};
let d = b.field(kind, out_w);
let mut s = stage(Kernel::MStroke, Some(d), None, [out_w, 0.0, 0.0, 0.0], 0);
s.out = Some(0);
b.stages.push(s);
@@ -522,7 +530,8 @@ pub(crate) fn field(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, ou
/// (the chamfer fields, a sequential sweep) the window is cut into bands that go in parallel. Without
/// threads (wasm) there is nothing to spread over, and bands keep the memory small.
fn threaded_inside(kind: FieldKind) -> bool {
!cfg!(target_arch = "wasm32") && matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside)
!cfg!(target_arch = "wasm32")
&& matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside | FieldKind::StrokeOutsideExact)
}
/// [`field`] with the halo given (the tests shrink it to see that they notice).
@@ -748,7 +757,9 @@ mod tests {
FieldKind::ChokeInside,
FieldKind::StrokeOutside,
FieldKind::StrokeInside,
FieldKind::StrokeOutsideExact,
FieldKind::StrokeOutsideVector,
FieldKind::StrokeOutsideVectorChamfer,
] {
let reach = 9;
let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h, reach as f32);
@@ -825,4 +836,42 @@ mod tests {
}
assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing");
}
/// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact
/// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU
/// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop
/// shadows and softer outer glows keeps the chamfer field (fitted to Photoshop, #224) — so
/// routing a wide stroke through the chamfer, or a shadow through the exact field, fails here.
#[test]
fn a_stroke_uses_the_chamfer_narrow_and_the_exact_field_wide() {
use photocraft_color::{BlendMode, Color};
use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition};
let light = GlobalLight::default();
let stroke =
|size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) });
// Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646.
let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false);
assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields);
assert!(!narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact), "…not the exact field: {:?}", narrow.fields);
// Wide: the exact transform.
let wide = program(&stroke(40.0, StrokePosition::Outside), &light, false);
assert!(
wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact),
"a wide raster outside stroke needs the exact outer field: {:?}",
wide.fields
);
assert!(!wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "…not the chamfer field: {:?}", wide.fields);
// A shape layer's outline stroke follows the same width rule; the inside edge keeps the chamfer.
let vector_narrow = program(&stroke(6.0, StrokePosition::Outside), &light, true);
assert!(vector_narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVectorChamfer), "{:?}", vector_narrow.fields);
let vector_wide = program(&stroke(40.0, StrokePosition::Outside), &light, true);
assert!(vector_wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVector), "{:?}", vector_wide.fields);
let inner = program(&stroke(40.0, StrokePosition::Inside), &light, false);
assert!(inner.fields.iter().any(|(k, _)| *k == FieldKind::StrokeInside), "{:?}", inner.fields);
// The shadow / softer-glow spread is unchanged: still the chamfer.
let Effect::DropShadow(mut s) = Effect::default_drop_shadow() else { unreachable!() };
s.spread = 0.5;
let shadow = program(&Effect::DropShadow(s), &light, false);
assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields);
}
}
+44 -13
View File
@@ -552,17 +552,18 @@ fn field_reaches(progs: &[fx::MapProgram]) -> HashMap<FieldKind, i32> {
want
}
/// Runs `f` inside validation and internal-error scopes and returns the first error raised.
/// Runs `f` inside GPU error scopes and returns the first error raised.
/// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none
/// is reported.
fn first_error<T>(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option<wgpu::Error>) {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let out = f();
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut error = None;
// Scopes pop in reverse order.
for pop in [internal.pop(), validation.pop()] {
for pop in [memory.pop(), internal.pop(), validation.pop()] {
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
error.get_or_insert(e);
}
@@ -570,6 +571,21 @@ fn first_error<T>(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option<wg
(out, error)
}
/// Unwatched native callers need GPU errors to become `Unsupported` instead of reaching wgpu's
/// default panic handler. Watched callers keep their fault callbacks, which stop work promptly
/// and update the health flag shared with the canvas.
fn scoped_work<T>(device: &wgpu::Device, watched: bool, f: impl FnOnce() -> Result<T, Unsupported>) -> Result<T, Unsupported> {
// WebGPU errors arrive asynchronously; its callers still use the health callbacks.
if watched || cfg!(target_arch = "wasm32") {
return f();
}
let (result, error) = first_error(device, f);
match error {
Some(error) => Err(Unsupported(format!("GPU work failed: {error}"))),
None => result,
}
}
impl Compositor {
/// Create a compositor with the default accumulation format ([`ACC_FORMAT`]).
pub fn new(device: &wgpu::Device) -> Self {
@@ -902,16 +918,16 @@ impl Compositor {
region: Rect,
mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
) -> Result<Stats, Unsupported> {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") });
let stats = match self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true) {
Ok(stats) => stats,
Err(error) => {
self.prefix.clear();
return Err(error);
}
};
queue.submit([encoder.finish()]);
Ok(stats)
let result = scoped_work(device, self.health.is_some(), || {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") });
let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?;
queue.submit([encoder.finish()]);
Ok(stats)
});
if result.is_err() {
self.discard_failed_work();
}
result
}
/// Like [`Self::render`] but records into `encoder` without submitting.
@@ -924,7 +940,22 @@ impl Compositor {
region: Rect,
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
) -> Result<Stats, Unsupported> {
self.encode_inner(device, queue, encoder, doc, region, sink, false)
let result = scoped_work(device, self.health.is_some(), || self.encode_inner(device, queue, encoder, doc, region, sink, false));
if result.is_err() {
self.discard_failed_work();
}
result
}
fn discard_failed_work(&mut self) {
// Cache metadata advances while recording. A failed encoder may never execute its
// texture uploads or effect maps, so none of those cached pixels can be trusted.
self.prefix.clear();
self.residents.clear();
self.fx.clear();
self.patterns.clear();
self.temps.clear();
self.pool.clear();
}
#[allow(clippy::too_many_arguments)]
+178
View File
@@ -81,3 +81,181 @@ fn health_wait_on_a_destroyed_device_is_safe() {
assert!(!health.is_ok());
assert!(!health.wait(&device, None));
}
fn assert_unwatched_destroyed_device(encode_only: bool) {
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((adapter, device, queue)) = device() else { return };
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
let d = Document::with_background("synthetic", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0));
assert!(render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).is_ok());
let mut encoder = device.create_command_encoder(&Default::default());
device.destroy();
let mut changed = d.clone();
changed.layers[0].opacity = 0.4;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if encode_only {
comp.encode(&device, &queue, &mut encoder, &changed, changed.bounds(), &mut |_, _| {})
} else {
comp.render(&device, &queue, &changed, changed.bounds(), |_, _| {})
}
}));
assert!(result.is_ok(), "an unwatched device failure must return Unsupported instead of panicking");
let error = result.unwrap().expect_err("a destroyed device must not report successful GPU work");
assert!(error.0.contains("GPU work failed"), "{error}");
}
#[test]
fn an_unwatched_destroyed_device_returns_an_error_without_panicking() {
assert_unwatched_destroyed_device(false);
}
#[test]
fn an_unwatched_encode_on_a_destroyed_device_returns_an_error_without_panicking() {
assert_unwatched_destroyed_device(true);
}
#[test]
fn loss_during_an_unwatched_render_returns_an_error_without_panicking() {
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((adapter, device, queue)) = device() else { return };
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
comp.set_texture_limit(256);
let d = Document::with_background("synthetic", Size::new(512, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0));
let mut chunks = 0;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
comp.render(&device, &queue, &d, d.bounds(), |_, _| {
chunks += 1;
if chunks == 1 {
// A driver reset can arrive after the entry-point health check too.
device.destroy();
}
})
}));
assert!(chunks > 0, "the injection must occur after GPU work began");
assert!(result.is_ok(), "device loss mid-render must return Unsupported instead of panicking");
assert!(result.unwrap().is_err(), "a partially failed render must not report success");
}
#[test]
fn a_captured_render_error_preserves_the_device_for_a_later_render() {
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((adapter, device, queue)) = device() else { return };
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
let d = Document::with_background("synthetic", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
comp.render(&device, &queue, &d, d.bounds(), |_, _| {
// Reject a buffer above the device's valid capability without actually allocating it.
let _ = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("synthetic_invalid_buffer"),
size: device.limits().max_buffer_size + 1,
usage: wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
})
}));
assert!(result.is_ok(), "GPU validation errors must return Unsupported instead of panicking");
let error = result.unwrap().expect_err("an invalid allocation must fail this render");
assert!(error.0.contains("synthetic_invalid_buffer"), "{error}");
let out = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).expect("the next fitting render keeps working");
let expected = photocraft_compose::flatten(&d);
for (cpu, gpu) in expected.px[0].iter().zip(out[0]) {
assert!((cpu - gpu).abs() <= 1.0 / 255.0, "the healthy GPU control differs: {cpu} vs {gpu}");
}
}
fn assert_recovery_after_invalid_encoder(encode_only: bool) {
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((adapter, device, queue)) = device() else { return };
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
let mut d = doc();
d.layers[1].effects.items = vec![photocraft_doc::Effect::default_drop_shadow()];
// Pipeline creation errors are not proof of missing hardware capabilities: check the
// adapter and the features actually enabled on this device before allowing a skip.
let required = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let features = device.features();
for format in [wgpu::TextureFormat::R32Float, wgpu::TextureFormat::R16Float] {
let adapter_usages = adapter.get_texture_format_features(format).allowed_usages;
let guaranteed_usages = format.guaranteed_format_features(features).allowed_usages;
if !adapter_usages.contains(required)
|| (!features.contains(wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES) && !guaranteed_usages.contains(required))
{
eprintln!(
"skipping effect-map recovery test: {:?} {format:?} lacks {required:?}; adapter usages={adapter_usages:?}, guaranteed usages={guaranteed_usages:?}, device features={features:?}",
adapter.get_info().backend,
);
return;
}
}
comp.supports(&d).expect("a capable adapter must support the recovery fixture's effect-map pipelines");
let src = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 4, usage: wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false });
let dst = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 4, usage: wgpu::BufferUsages::COPY_DST, mapped_at_creation: false });
let mut injected = false;
let mut sink = |encoder: &mut wgpu::CommandEncoder, _: photocraft_gpu::ChunkOut<'_>| {
injected = true;
// Misaligned copies invalidate the encoder, discarding all recorded effect-map work.
encoder.copy_buffer_to_buffer(&src, 0, &dst, 0, 1);
if encode_only {
// encode does not finish its caller-owned encoder; force an immediate error too.
let _ = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("synthetic_invalid_buffer"),
size: device.limits().max_buffer_size + 1,
usage: wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
};
let result = if encode_only {
let mut encoder = device.create_command_encoder(&Default::default());
comp.encode(&device, &queue, &mut encoder, &d, d.bounds(), &mut sink)
} else {
comp.render(&device, &queue, &d, d.bounds(), sink)
};
assert!(injected, "the invalid encoder injection must reach the sink");
let error = result.expect_err("invalid encoder must fail");
assert!(error.0.contains("GPU work failed"), "the injected GPU error must be captured: {error}");
let recovered = render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).unwrap();
let mut fresh = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
let expected = render_to_vec(&mut fresh, &device, &queue, &d, d.bounds()).unwrap();
for (a, b) in recovered.iter().zip(expected.iter()) {
for (a, b) in a.iter().zip(b.iter()) {
assert!((a - b).abs() <= 1.0 / 255.0, "recovered cached pixels differ: {a} vs {b}");
}
}
}
#[test]
fn render_recovers_effect_maps_after_invalid_encoder() {
assert_recovery_after_invalid_encoder(false);
}
#[test]
fn encode_recovers_effect_maps_after_invalid_encoder() {
assert_recovery_after_invalid_encoder(true);
}
#[test]
fn loss_during_intermediate_unwatched_submissions_returns_an_error() {
let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((adapter, device, queue)) = device() else { return };
let mut comp = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap();
comp.set_texture_limit(256);
let mut d = Document::with_background("synthetic", Size::new(2048, 1), ColorMode::Rgb, SampleType::U8, Color::rgba(0.9, 0.9, 0.9, 1.0));
for _ in 0..300 {
let mut layer = d.layers[0].clone();
layer.id = photocraft_doc::LayerId::fresh();
layer.opacity = 0.5;
d.layers.push(layer);
}
let mut chunks = 0;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
comp.render(&device, &queue, &d, d.bounds(), |_, _| {
chunks += 1;
if chunks == 1 {
device.destroy();
}
})
}));
assert!(chunks > 0);
assert!(result.is_ok(), "intermediate submit polling must not escape as a panic");
assert!(result.unwrap().is_err());
}
+28 -2
View File
@@ -50,6 +50,14 @@ impl<'a> Rd<'a> {
/// Parses one item; `None` for malformed data or non-`liFD` items.
fn parse_item(item: &[u8]) -> Option<LinkedFile> {
let (uuid, file_name, start, len) = item_header(item)?;
let bytes = item.get(start..start.checked_add(len)?)?.to_vec();
Some(LinkedFile { uuid, file_name, bytes })
}
/// The uuid and file name of an embedded (`liFD`) item, and where its file bytes start and how
/// many there are; `None` for malformed data or other items.
fn item_header(item: &[u8]) -> Option<(String, String, usize, usize)> {
let mut r = Rd { d: item, p: 0 };
let kind = r.take(4)?;
let _version = r.u32()?;
@@ -65,8 +73,26 @@ fn parse_item(item: &[u8]) -> Option<LinkedFile> {
if kind != b"liFD" {
return None;
}
let bytes = r.take(len)?.to_vec();
Some(LinkedFile { uuid, file_name, bytes })
Some((uuid, file_name, r.p, len))
}
/// The file name of the embedded file for smart-object `uuid` among the document's preserved PSD
/// global blocks, without copying its bytes: whether a placed layer's source is embedded.
pub fn embedded_file_name(meta: &Metadata, uuid: &str) -> Option<String> {
if uuid.is_empty() {
return None;
}
meta.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).find_map(|(_, _, d)| {
let mut r = Rd { d, p: 0 };
while let Some(len) = r.u64() {
let item = r.take(usize::try_from(len).ok()?)?;
if let Some((_, name, ..)) = item_header(item).filter(|h| h.0 == uuid) {
return Some(name);
}
r.p = r.p.next_multiple_of(4);
}
None
})
}
/// Every embedded (`liFD`) file in one linked-layer block's data.
+100 -8
View File
@@ -79,6 +79,32 @@ struct Ex {
smart: SmartOut,
/// The document's patterns, which Pattern Fill layers are rendered from.
patterns: Vec<photocraft_doc::Pattern>,
/// The layer written as Photoshop's Background (see [`psd_background`]).
background: Option<photocraft_doc::LayerId>,
}
/// The layer Photoshop should open as its Background: the bottom top-level layer when it is a
/// locked (transparency and position), unclipped, unmasked raster that is opaque over the whole
/// canvas at full opacity and Normal, as a Background always is. Photoshop recognises a
/// Background by the bottom layer having no transparency channel (measured on Photoshop 27.11:
/// with the `-1` channel written, 317 of 427 re-exported corpus files reopened with the
/// Background as an ordinary layer).
fn psd_background(doc: &Document) -> Option<photocraft_doc::LayerId> {
let l = doc.layers.first()?;
let LayerContent::Raster(s) = &l.content else { return None };
let locked = l.locks.transparency && l.locks.position;
let plain = !l.clipped && l.mask.is_none() && l.vector_mask.is_none() && l.opacity >= 1.0 && l.fill_opacity >= 1.0;
let normal = l.blend == photocraft_doc::BlendMode::Normal;
if !(locked && plain && normal) {
return None;
}
let canvas = doc.bounds();
if canvas.is_empty() || s.content_bounds() != canvas {
return None;
}
let n = s.channels();
let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0));
opaque.then_some(l.id)
}
/// How deep embedded documents are exported inside each other before a smart object is written
@@ -272,6 +298,10 @@ fn upsert(raw: &mut Vec<([u8; 4], Vec<u8>)>, key: &[u8; 4], data: Vec<u8>, befor
}
}
fn h_of(r: photocraft_geom::Rect) -> usize {
r.height() as usize
}
fn psd_mode(m: ColorMode) -> PsdMode {
match m {
ColorMode::Grayscale => PsdMode::Grayscale,
@@ -309,18 +339,28 @@ impl Ex {
(-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect()
}
fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec<ChannelData>) {
fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec<ChannelData>, Vec<String>) {
let mut warnings = Vec::new();
let s = if s.format() != self.fmt {
self.warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt));
warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt));
s.convert(self.fmt)
} else {
s.clone()
};
let r = s.content_bounds();
if r.is_empty() {
return (PsdRect::default(), self.empty_channels());
return (PsdRect::default(), self.empty_channels(), warnings);
}
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
// in row bands on all cores and the band results concatenate in order.
let row_len = r.width() as usize;
let bands = crate::pixels::bands(h_of(r));
let parts =
crate::pixels::par_map(bands, |rows| s.to_interleaved(photocraft_geom::Rect::new(r.x0, r.y0 + rows.start as i32, r.x1, r.y0 + rows.end as i32)));
let mut bytes = Vec::with_capacity(row_len * h_of(r) * s.format().bytes_per_pixel());
for part in parts {
bytes.extend_from_slice(&part);
}
let mut bytes = s.to_interleaved(r);
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
}
@@ -333,7 +373,7 @@ impl Ex {
let order: Vec<(i16, &Vec<u8>)> =
std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect();
let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h));
(to_psd_rect(r), ch)
(to_psd_rect(r), ch, warnings)
}
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
@@ -384,10 +424,11 @@ impl Ex {
fn record(&mut self, l: &Layer, extra: Vec<TaggedBlock>, pixels: Option<&Surface>) -> LayerRecord {
let id = self.layer_id(l);
let (rect, mut channels) = match pixels {
let (rect, mut channels, warnings) = match pixels {
Some(s) => self.pixel_channels(s, &l.name),
None => (PsdRect::default(), self.empty_channels()),
None => (PsdRect::default(), self.empty_channels(), Vec::new()),
};
self.warnings.extend(warnings);
let mut mask = MaskData::None;
// Vector mask density/feather live in the mask parameters, with or without a user mask.
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
@@ -407,7 +448,16 @@ impl Ex {
real_first: true,
});
}
let mut flags = LayerFlags(0);
// A Background carries no transparency channel: that is how Photoshop tells it apart.
if self.background == Some(l.id) {
channels.retain(|c| c.id != -1);
}
// As Photoshop writes them: bit 4 is meaningful, and set for layers whose pixels don't
// show (adjustment layers).
let mut flags = LayerFlags(LayerFlags::BIT4_USEFUL);
if matches!(l.content, LayerContent::Adjustment(_)) {
flags.0 |= LayerFlags::PIXEL_DATA_IRRELEVANT;
}
flags.set_hidden(!l.visible);
flags.set_transparency_protected(l.locks.transparency);
let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)];
@@ -1459,6 +1509,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
comps: (!crate::comps_map::comps_unchanged(doc)).then(|| (doc.layer_comps.clone(), doc.last_document_state.clone())),
smart: SmartOut::new(doc, depth),
patterns: doc.patterns.clone(),
background: psd_background(doc),
};
if big && !opts.force_psb {
ex.warnings.push("document exceeds 30000 px; written as PSB".into());
@@ -1751,6 +1802,47 @@ mod tests {
Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8)
}
/// Photoshop opens a PSD's bottom layer as its Background only when that layer has no
/// transparency channel (measured on Photoshop 27.11: with the channel written, re-exported
/// files reopened with the Background as an ordinary layer).
#[test]
fn a_background_is_written_without_a_transparency_channel() {
use photocraft_color::Color;
let size = photocraft_geom::Size::new(8, 6);
let mut doc = Document::with_background("bg", size, ColorMode::Rgb, SampleType::U8, Color::rgb(0.2, 0.4, 0.6));
doc.layers.push(Layer::raster("Layer 1", doc.pixel_format()));
let file = document_to_psd(&doc);
let layers = &file.layer_info.as_ref().unwrap().layers;
let ids = |i: usize| layers[i].channels.iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(0), vec![0, 1, 2], "the Background has no -1 channel");
assert_eq!(layers[0].flags.0, LayerFlags::BIT4_USEFUL | LayerFlags::TRANSPARENCY_PROTECTED, "as Photoshop writes it");
assert!(ids(1).contains(&-1), "other layers keep their transparency");
// It comes back as the same locked, opaque Background.
let back = crate::import("bg.psd", &file.to_bytes().unwrap()).unwrap().document;
let bg = &back.layers[0];
assert!(bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
assert_eq!(bg.surface().unwrap().read_region(back.bounds()), doc.layers[0].surface().unwrap().read_region(doc.bounds()));
// A bottom layer with transparency (or unlocked) is not a Background.
let mut holey = doc.clone();
holey.layers[0].surface_mut().unwrap().write_pixel(2, 2, &[0.0, 0.0, 0.0, 0.0]);
assert!(document_to_psd(&holey).layer_info.unwrap().layers[0].channels.iter().any(|c| c.id == -1));
let mut unlocked = doc.clone();
unlocked.layers[0].locks.position = false;
assert!(document_to_psd(&unlocked).layer_info.unwrap().layers[0].channels.iter().any(|c| c.id == -1));
}
/// A flat 1-bit Bitmap file (no layers) opens in Photoshop as its Background; so does it in
/// PhotoCraft, and it is written back as one (no transparency channel).
#[test]
fn a_flat_bitmap_file_opens_and_saves_as_a_background() {
let file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Bitmap, 1, Compression::Raw, 8, 4);
let doc = crate::import("flat.psd", &file.to_bytes().unwrap()).unwrap().document;
let bg = &doc.layers[0];
assert!(bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks);
let out = document_to_psd(&doc);
assert!(out.layer_info.unwrap().layers[0].channels.iter().all(|c| c.id >= 0));
}
#[test]
fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() {
let mut doc = document(8, 8);
+61 -18
View File
@@ -163,6 +163,13 @@ impl Ctx<'_> {
}
}
/// [`Self::channel_plane`] without the warnings sink, so a layer's channels decode
/// concurrently (their RLE rows are the bulk of the open time on full-canvas layers).
fn channel_plane_decoded(&self, rec: &LayerRecord, id: i16) -> Option<Vec<u8>> {
rec.channel(id)?;
rec.decode_channel(id, self.file.header.depth, self.file.header.version).ok()
}
fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface {
let r = rec.rect;
if r.is_empty() || r.size().is_err() {
@@ -170,15 +177,28 @@ impl Ctx<'_> {
}
let (w, h) = r.size().unwrap_or((0, 0));
let s = self.fmt.sample;
let mut planes = Vec::with_capacity(self.cc + 1);
for c in 0..self.cc {
planes.push(self.channel_plane(rec, c as i16, name));
}
planes.push(self.channel_plane(rec, CHANNEL_TRANSPARENCY, name));
let ids: Vec<i16> = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]).collect();
let planes: Vec<Option<Vec<u8>>> = if cfg!(target_arch = "wasm32") {
ids.iter().map(|&id| self.channel_plane(rec, id, name)).collect()
} else {
// The channels decode independently; their RLE rows are the bulk of the open
// time for full-canvas layers, so they run side by side and the warnings land
// in the same order afterwards.
// par_map reuses the crate's worker pool (one OS thread per channel per layer
// would spawn thousands on a file with many layers).
let decoded: Vec<Option<Vec<u8>>> = crate::pixels::par_map(ids.to_vec(), |id| self.channel_plane_decoded(rec, id));
let mut planes: Vec<Option<Vec<u8>>> = Vec::with_capacity(decoded.len());
for (&id, p) in ids.iter().zip(decoded) {
if p.is_none() && rec.channel(id).is_some() {
self.warn(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty"));
}
planes.push(p);
}
planes
};
// A channel that is in the file but could not be decoded leaves the layer empty, as the
// warning says. Filling in for it would paint an opaque black layer over the document.
let ids = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]);
if ids.zip(&planes).any(|(id, p)| p.is_none() && rec.channel(id).is_some()) {
if ids.iter().zip(planes.iter()).any(|(&id, p)| p.is_none() && rec.channel(id).is_some()) {
return Surface::new(self.fmt);
}
// Only decoded channel data justifies a buffer: the decoders bound their output by the
@@ -599,6 +619,35 @@ fn parse_guides(data: &[u8], doc: &mut Document) {
}
/// Converts a parsed PSD into a document. Never fails: problems become warnings.
/// A flattened file's merged image as its layer: like Photoshop, an opaque one is the locked
/// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is
/// written back as one.
fn flat_background(s: Surface) -> Layer {
let opaque = {
let r = s.content_bounds();
let n = s.channels();
!r.is_empty() && s.read_region(r).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0))
};
let mut l = Layer::new("Background", LayerContent::Raster(s));
l.locks.transparency = opaque;
l.locks.position = opaque;
l
}
/// Photoshop opens a file's bottom layer as its Background when that layer's record has no
/// transparency (`-1`) channel, whatever its name and lock flags say: it is then locked and shown
/// as "Background" (measured on Photoshop 27.11 with files from psd-tools and ag-psd whose bottom
/// layer is called "Фон", "bg" or "Layer 1" and carries no locks).
fn mark_background(file: &PsdFile, layers: &mut [Layer]) {
let Some(rec) = file.layers().first() else { return };
let Some(bottom) = layers.first_mut() else { return };
let opaque_record = !rec.channels.is_empty() && rec.channels.iter().all(|c| c.id >= 0);
if opaque_record && matches!(bottom.content, LayerContent::Raster(_)) && !bottom.clipped {
bottom.locks.transparency = true;
bottom.locks.position = true;
}
}
pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
// Never cancelled, so always `Some`; the fallback is unreachable.
psd_to_document_with(file, &photocraft_raster::Interrupt::NONE)
@@ -759,16 +808,10 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// its own profile, as the compositor and the PSD writer read them.
let space = photocraft_compose::cmyk_space(&doc);
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
// without a transparency channel (-1). It is also position-locked, whatever its
// (localized) name and whether or not the file carries an `lspf` block.
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
&& rec.flags.transparency_protected()
&& !rec.channels.iter().any(|c| c.id == -1)
&& matches!(bottom.content, LayerContent::Raster(_))
{
bottom.locks.position = true;
}
// Photoshop's Background: the bottom record stored without a transparency channel (-1),
// locked whatever its (localized) name and whether or not the file carries an `lspf`
// block. Its name is kept.
mark_background(file, &mut doc.layers);
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
@@ -865,7 +908,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
.collect();
s.write_region(canvas, &vals);
s.prune();
doc.layers.push(Layer::new("Background", LayerContent::Raster(s)));
doc.layers.push(flat_background(s));
}
}
+6 -5
View File
@@ -213,7 +213,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
ctl.progress(TIFF_LAYERS_DONE);
warnings.extend(w.into_iter().map(|w| format!("layer data: {w}")));
// The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource
// (1058): the TIFF codec has no EXIF directory, and the PSD importer reads it back from there.
// (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own
// Exif directory keeps only the Exif, GPS and descriptive tags.
let resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
let resources = resources_to_bytes(&resources)?;
@@ -235,10 +236,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
let mut img = Image::from_raw(w, h, layout, sample, data)?;
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
img.meta = codecs::Metadata {
// EXIF is deliberately not set here: the TIFF codec has no EXIF directory, so the tags
// above carry it as resource 1058 and the importer reads them back from there. Handing the
// codec a second copy in `meta.exif` bought nothing and made the export claim it would
// drop metadata it had in fact kept (#1545).
// EXIF is deliberately not set here: the tags above carry it as resource 1058 and the
// importer reads it back from there unchanged. A second copy in `meta.exif` (the codec's
// Exif directory) would be read back in its place, rebuilt without the thumbnail and IFD0
// orientation (#1545).
xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All),
text: if opts.xmp == crate::XmpEmbed::All { doc.metadata.text.clone() } else { Vec::new() },
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
+28
View File
@@ -0,0 +1,28 @@
//! The parallel RLE/decode paths must not change output bytes: a document exported by this
//! branch re-exports byte-equal after a decode/encode cycle, and matches main's exporter
//! (verified by the corpus round-trip floors in `corpus.rs`; here the byte-equality of the
//! codec layer itself is pinned).
use photocraft_psd::compression::{Compression, PlaneLayout, decode_planes, encode_planes};
use photocraft_psd::header::Version;
#[test]
fn encode_decode_planes_round_trip_exactly() {
let mut data: u64 = 0x0123_4567_89ab_cdef;
let mut next = move || {
data ^= data << 13;
data ^= data >> 7;
data ^= data << 17;
data
};
for (version, planes, w, h, depth) in
[(Version::Psd, 1usize, 640usize, 480usize, 8u16), (Version::Psb, 1, 15000, 10000, 8), (Version::Psd, 4, 513, 65, 8), (Version::Psb, 3, 64, 2049, 16)]
{
let layout = PlaneLayout { planes, width: w, height: h, depth, version };
let n = layout.decoded_len().expect("size");
let pattern: Vec<u8> = if h % 2 == 0 { vec![42u8; n] } else { (0..n).map(|i| ((i as u64 ^ next()) % 251) as u8).collect() };
let encoded = encode_planes(Compression::Rle, &pattern, &layout).expect("encode");
let decoded = decode_planes(Compression::Rle, &encoded, &layout).expect("decode");
assert_eq!(decoded, pattern, "{version:?} {planes}x{w}x{h} d{depth}");
}
}
+52
View File
@@ -553,3 +553,55 @@ fn blend_if_round_trips_as_blending_ranges() {
assert!(back.layers[1].blend_if.is_default());
assert_eq!(back.layers[2].blend_if.get(1)[0], BlendRange { black: [1, 2], white: [3, 4] });
}
#[test]
fn psd_16bit_layers_with_compatibility_section_open_visible() {
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
// A 16-bit document with multiple distinct user layers.
let mut doc = Document::new("Test16", Size::new(50, 50), ColorMode::Rgb, SampleType::U16);
let fmt = doc.pixel_format();
let bg_data = vec![255u8; 50 * 50 * 8];
let bg_surf = Surface::from_interleaved(fmt, Rect::new(0, 0, 50, 50), &bg_data);
let mut bg = Layer::new("Background", LayerContent::Raster(bg_surf));
bg.visible = true;
doc.layers.push(bg);
let fg_data = vec![128u8; 20 * 20 * 8];
let fg_surf = Surface::from_interleaved(fmt, Rect::new(10, 10, 30, 30), &fg_data);
let mut fg = Layer::new("Foreground", LayerContent::Raster(fg_surf));
fg.visible = true;
doc.layers.push(fg);
let exported = export(&doc, "test.psd", &ExportOptions::default()).expect("export");
let bytes = exported.bytes;
let cmd_len = u32::from_be_bytes(bytes[26..30].try_into().unwrap()) as usize;
let res_pos = 30 + cmd_len;
let res_len = u32::from_be_bytes(bytes[res_pos..res_pos + 4].try_into().unwrap()) as usize;
let lm_pos = res_pos + 4 + res_len;
let lm_len = u32::from_be_bytes(bytes[lm_pos..lm_pos + 4].try_into().unwrap()) as usize;
let li_pos = lm_pos + 4;
// Simulate Photoshop's 16-bit format: an empty/compatibility layer info section (count = 0)
// before the global Lr16 block (#4928).
let mut modified = bytes;
let new_lm_len = (lm_len + 4) as u32;
modified[lm_pos..lm_pos + 4].copy_from_slice(&new_lm_len.to_be_bytes());
modified[li_pos..li_pos + 4].copy_from_slice(&4u32.to_be_bytes());
modified.splice(li_pos + 4..li_pos + 4, [0u8, 0, 0, 0]);
let imported = import("test.psd", &modified).expect("import").document;
// Must import the 16-bit layers from Lr16 ("Background" and "Foreground").
assert_eq!(imported.layers.len(), 2);
assert_eq!(imported.layers[0].name, "Background");
assert_eq!(imported.layers[1].name, "Foreground");
assert!(imported.layers[0].visible);
assert!(imported.layers[1].visible);
assert!(imported.layers[0].surface().unwrap().tile_count() > 0);
assert!(imported.layers[1].surface().unwrap().tile_count() > 0);
}
+7 -3
View File
@@ -127,7 +127,7 @@ fn exif() -> Vec<u8> {
}
/// #1545: a layered TIFF carries EXIF as Photoshop resource 1058 and reads it back, so it must
/// not report a loss it never makes. A flat TIFF really does lose the tags and still says so.
/// not report a loss it never makes. A flat TIFF writes it as its own tags and keeps it too.
#[test]
fn layered_tiff_keeps_exif_without_claiming_a_loss() {
let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
@@ -140,9 +140,13 @@ fn layered_tiff_keeps_exif_without_claiming_a_loss() {
let kept = back.metadata.exif.as_deref().expect("EXIF survives the layered export");
assert!(kept.windows(ARTIST.len()).any(|w| w == ARTIST), "the Artist tag did not survive: {kept:?}");
// The control from the issue: the flat path drops it for real, and keeps reporting that.
// The flat path writes the EXIF block's tags as the TIFF's own (here IFD0's Artist, which
// reads back as the document's Artist text), so it reports no loss either.
let flat = tiff(&d, false);
assert!(flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF really does lose EXIF: {:?}", flat.warnings);
assert!(!flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF keeps EXIF: {:?}", flat.warnings);
let back = import("x.tif", &flat.bytes).unwrap().document;
let artist = std::str::from_utf8(ARTIST).unwrap().trim_end_matches('\0');
assert!(back.metadata.text.iter().any(|(k, v)| k == "Artist" && v == artist), "{:?}", back.metadata.text);
}
#[test]
+38
View File
@@ -581,6 +581,44 @@ mod tests {
}
}
/// A pattern fill or layer style copies its pattern into the document, so the pattern's tiles
/// count like layer pixels, shared ones once; undoing that step leaves them to the redo states.
#[test]
fn document_patterns_count_as_document_pixels() {
use photocraft_doc::{Pattern, Surface};
for depth in SampleType::ALL {
let tile = 4 * 256 * 256 * depth.bytes();
let mut cur = Arc::new(Document::new("patterns", Size::new(64, 64), ColorMode::Rgb, depth));
let mut h = History { max_bytes: usize::MAX, ..History::default() };
for name in ["A", "B"] {
edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into());
h.trim(&cur);
}
let mut surface = Surface::new(cur.pixel_format());
surface.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
let pattern = Pattern::new("Pattern", surface, 4, 4);
edit(&mut h, &mut cur, "Pattern Fill", |doc| doc.patterns.push(pattern));
assert_eq!(h.pixel_bytes(&cur), tile, "the document's pattern");
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
edit(&mut h, &mut cur, "Rename", |doc| doc.name = "C".into());
assert_eq!(h.pixel_bytes(&cur), tile, "shared with history, it counts once");
assert_counter(&h, &cur);
for _ in 0..2 {
cur = h.undo(cur).unwrap().0;
assert_counter(&h, &cur);
}
assert!(cur.patterns.is_empty());
assert_eq!(h.unique_bytes(&cur), tile, "only the redo states hold the pattern");
assert_eq!(h.pixel_bytes(&cur), tile);
cur = h.redo(cur).unwrap().0;
assert_eq!(h.unique_bytes(&cur), 0);
assert_counter(&h, &cur);
h.clear();
assert_eq!(h.pixel_bytes(&cur), tile);
}
}
#[test]
fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more