diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 87050381a5e..a81cce61cec 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -298,13 +298,14 @@ fn command_list(a: &Args) -> Result, 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::::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 = 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 = - serde_json::from_value::>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect(); + let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + let ws: Vec = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect(); warn_all(err, &ws); } Ok(()) diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index 6c9ef84addb..a7445bbc145 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -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()); diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 8d7e8e08261..a5c9d8a5653 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -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. diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index bef5bb41f6e..8632fd2a55b 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -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 { + (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 { 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); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 938fc1f032c..1739568159b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -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()})) diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index 4f226dcae4f..c4de67a9941 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -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::>(text) else { return false }; + let Some(window) = kv.get("window").and_then(|w| ron::from_str::(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. diff --git a/crates/algo/src/dff.rs b/crates/algo/src/dff.rs new file mode 100644 index 00000000000..c1cf05af632 --- /dev/null +++ b/crates/algo/src/dff.rs @@ -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, + /// Full-resolution confidence in `0 ..= 1`. + pub conf: Vec, + 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, 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::(); + } + } + 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 { + 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, +} + +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 { + 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, + mean_i: Vec, + var_i: Vec, + bf: BoxFilter, + eps: f32, +} + +impl GuidedFilter { + fn new(guide: Vec, 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 = guide.iter().map(|v| v * v).collect(); + let var_i: Vec = 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, Vec) { + let mean_p = self.bf.mean(p); + let ip: Vec = 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 { + 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 { + 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, + 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, Vec) { + 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 = 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 = 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 { + 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, Vec) { + 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 { + 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 { + 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, + /// `λ·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, + ay: Vec, + /// `1 / diag(A)`. + dinv: Vec, + x: Vec, + b: Vec, + r: Vec, +} + +/// 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 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::(); + } + } + }); + 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::(); + } + } + }); + } +} + +/// `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::(); + } + } + }); +} + +/// 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, + ey: Vec, + levels: Vec, +} + +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) { + 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 = 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 = 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 = 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 = 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 = (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 = (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::().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 = (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 = (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> = (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 = (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)); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 9d6ba294815..78dae7b06f1 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -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, ) -> 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, ctl: &photocraft_raster::Interrupt, - prepare_radial: bool, + prepare_fast_paths: bool, ) -> Option { 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::())? > 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 { diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index e21b7874ee8..c3c5c3693c2 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -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 { + 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)> { 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::>()); + 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 = 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 = 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); diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 12aac4c58a2..f7c1c18557e 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -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, win: (usize, usize, usize, usize), sigma: f32) -> Vec { 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 = (-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 = 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 { diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index ae1a586c12f..573c09079e3 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -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, Cancelled> { + let cancel = || ctl.cancelled(); let beta = contrast_beta(img); let fixed: Vec = 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, diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index 90cc2f502cc..5c190e643a8 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -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 { /// 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 { + 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, 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) { + let cancel = || ctl.cancelled(); + let run = |t: &Rect| -> Result<(Rect, Vec), 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)> = { use rayon::prelude::*; - tiles.par_iter().map(run).collect() + tiles.par_iter().map(run).collect::>()? }; #[cfg(target_arch = "wasm32")] - let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect(); + let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect::>()?; 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. diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index 6002672adb7..b117bb5ef6c 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -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 { + 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, 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 { } } 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 = 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). diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 869f3c67177..55ddc6cafdf 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -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 = (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::>(), 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(®, &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(®, &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); diff --git a/crates/algo/src/sharpen.rs b/crates/algo/src/sharpen.rs index 175de2b37ad..9e87c6b8410 100644 --- a/crates/algo/src/sharpen.rs +++ b/crates/algo/src/sharpen.rs @@ -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 { 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 { - 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 { diff --git a/crates/algo/src/sharpen/exact.rs b/crates/algo/src/sharpen/exact.rs new file mode 100644 index 00000000000..7d74bc538fc --- /dev/null +++ b/crates/algo/src/sharpen/exact.rs @@ -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> { + 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> { + 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) +} diff --git a/crates/algo/src/sharpen/fft.rs b/crates/algo/src/sharpen/fft.rs new file mode 100644 index 00000000000..6fdf18c8b0d --- /dev/null +++ b/crates/algo/src/sharpen/fft.rs @@ -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; + +fn buffer(len: usize, value: T) -> Option> { + if len.checked_mul(std::mem::size_of::())? > 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>, + inverse: Arc>, + spectrum: Vec, + scratch: usize, +} +impl Plan { + fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner) -> Option { + 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, Vec)> { + 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 { + 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, + ctl: &Interrupt, +) -> Option> { + 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, ctl: &Interrupt) -> Option { + 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::::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> = 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> = (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; diff --git a/crates/algo/src/sharpen/fft/tests.rs b/crates/algo/src/sharpen/fft/tests.rs new file mode 100644 index 00000000000..e1f7a26ed80 --- /dev/null +++ b/crates/algo/src/sharpen/fft/tests.rs @@ -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::(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); +} diff --git a/crates/algo/src/sharpen/fft_bench.rs b/crates/algo/src/sharpen/fft_bench.rs new file mode 100644 index 00000000000..3081ff7cd30 --- /dev/null +++ b/crates/algo/src/sharpen/fft_bench.rs @@ -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::().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 = 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, ¶ms, area, area, None, Some(area), crate::auto_tile(¶ms, area)); + let candidate = || crate::apply_in(&surface, ¶ms, 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"}) + ); +} diff --git a/crates/algo/src/sharpen/reference.rs b/crates/algo/src/sharpen/reference.rs new file mode 100644 index 00000000000..a99665c187f --- /dev/null +++ b/crates/algo/src/sharpen/reference.rs @@ -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 { + if sigma < 0.05 { + return vec![1.0]; + } + let r = (sigma * 3.0).ceil() as i32; + let k: Vec = (-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 { + 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(src: &[f32], dst: &mut [f32], kernel: &[f32]) { + let pixels = src.as_chunks::().0; + for (x, out) in dst.as_chunks_mut::().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 { + 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 { + 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 } + }) +} diff --git a/crates/algo/src/sharpen/tests.rs b/crates/algo/src/sharpen/tests.rs new file mode 100644 index 00000000000..4699bb3a101 --- /dev/null +++ b/crates/algo/src/sharpen/tests.rs @@ -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 = (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, + 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 = (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::().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 = 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}) + ); + } +} diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 8592b3dda2a..2632d4a29a4 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -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 { 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. diff --git a/crates/algo/tests/retouch_blur_exact.rs b/crates/algo/tests/retouch_blur_exact.rs new file mode 100644 index 00000000000..871a52b7f4a --- /dev/null +++ b/crates/algo/tests/retouch_blur_exact.rs @@ -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 = (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, win: (usize, usize, usize, usize), sigma: f32) -> Vec { + 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 = (-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 = 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 +} diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 5d44f0e7a78..507010b9c88 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -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(); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index ea1b40239d0..b31507b0051 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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) -> Result { diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 574e13c40a1..0726228be51 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -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 = 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 { } } } - 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) } diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index b0b3ca46e91..afb7f967917 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -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; diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index b2b315e2636..5e09a68ece0 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -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>) -> 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, 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::(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 = 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::(); - let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::(); + let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::() + + tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::(); // 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 0.0 && d.1 > 0.0) } else { None }, text, diff --git a/crates/codecs/src/codecs/tiff_exif.rs b/crates/codecs/src/codecs/tiff_exif.rs new file mode 100644 index 00000000000..d07f555f30c --- /dev/null +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -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, +} + +/// 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, + pub exif: Vec, + pub gps: Vec, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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::(); + 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, 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( + enc: &mut TiffEncoder, + fields: &[Field], + from_le: bool, +) -> TiffResult { + 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( + 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(()) +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index dca235f80f6..d3730c08fb4 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -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, diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index c1f9d7b75fb..db59d4fa6be 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -150,6 +150,8 @@ pub enum DecodeWarning { MorePages { total: Option }, /// Only one part of a multi-part file was decoded; `total` counts every part when known. MoreParts { total: Option }, + /// 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, /// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i` diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 001cfab7e95..5a6d6ae75ea 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -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, +) -> Vec { + 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 = [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 = [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::new(); + for y in 0..h { + let pixels: Vec = (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 { + // 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 { 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"); +} diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/metadata.rs index 76ff9c93858..d8f4d80a9aa 100644 --- a/crates/codecs/tests/metadata.rs +++ b/crates/codecs/tests/metadata.rs @@ -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:?}"); diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/tiff_exif.rs new file mode 100644 index 00000000000..92e8c91df0a --- /dev/null +++ b/crates/codecs/tests/tiff_exif.rs @@ -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); + +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 { + [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, exif: Vec, gps: Vec) -> Vec { + 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::(); + 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, Vec, Vec) { + 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 { + (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)> { + let swap = |ty: u16, v: &[u8]| -> Vec { + 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 { + 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); +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 97d08f00da0..6966254547f 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -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 { /// 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 { if m == Metric::Chamfer { // Seeds start at their sub-pixel edge offset (1 − coverage). @@ -1657,9 +1675,14 @@ pub struct FxMaps { per: Vec>, bevel_paint: Vec, din: Option>, + /// Stroke outside distance below [`STROKE_CHAMFER_MAX`]: the fitted 5 × 5 chamfer. dout: Option>, - /// 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>, + /// Shape layers: chamfer distance outside the vector outline (from local coverage). vdout: Option>, + /// Shape layers: exact Euclidean distance outside the vector outline (from local coverage). + vdout_exact: Option>, /// `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 { @@ -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 = + 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>| 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, 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), } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8e88675fdea..8813b122d92 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6680f11a90c..f9e49ac04ed 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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 diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a5c5381ee1a..c4f7f523d15 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, pub channels: Vec, 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, 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, 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 { 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(); diff --git a/crates/engine/examples/bench_blur_tool.rs b/crates/engine/examples/bench_blur_tool.rs new file mode 100644 index 00000000000..c4702634402 --- /dev/null +++ b/crates/engine/examples/bench_blur_tool.rs @@ -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> { + 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); + } +} diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 738ef921f47..c1393bb94c9 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -334,14 +334,54 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { } fn replace_color(s: &mut Session, p: &Value) -> Result { + 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, 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>); @@ -454,7 +494,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f8f07db634..8fa053e86e8 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -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}"); +} diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 5885f0cdb7e..69fc2d76f31 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -73,7 +73,7 @@ pub fn default_for(kind: &str, mode: ColorMode) -> Result { /// 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 { - 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> { 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; 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(); diff --git a/crates/engine/src/align_cmds.rs b/crates/engine/src/align_cmds.rs index 26cd1acc395..b54f4b7a73e 100644 --- a/crates/engine/src/align_cmds.rs +++ b/crates/engine/src/align_cmds.rs @@ -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 { _ => 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 = st.doc.clone(); let mut area = Rect::EMPTY; @@ -223,9 +230,33 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result { 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 = 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 { 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::>(), "blended": new_layer.map(|l| l.0)})) + Ok(json!({"method": method, "layers": ids.iter().map(|i| i.0).collect::>(), "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 { 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()); + } + } + } } diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 1fd186f9316..370b6fcc788 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -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 { 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 { } 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 { 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 { 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 { 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 { + 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 { + 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 { + 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 { diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 1d44fe6e7d3..d91e9ec4d7b 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -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] diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 49ee1b06308..46331126ac2 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -119,6 +119,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { 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 { 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() })) diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index e6fa57333a4..39cb029b3d2 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -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::(0, "reset failpoint"); + assert!(result.is_err()); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 120ab36b46a..00f3ec4e31f 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -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(()) } diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 31dfe87f61d..d3a6d369311 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -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 { + 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(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 89278165e60..a32c5feb8d7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -573,7 +573,7 @@ fn build() -> Vec { "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 { "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 { 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 { 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()); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index baee260bee0..d5cc7346682 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -45,8 +45,12 @@ fn run(s: &mut Session, p: &Value) -> Result { 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]); diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs index 06490dbade2..1e4aafc909e 100644 --- a/crates/engine/src/display_color.rs +++ b/crates/engine/src/display_color.rs @@ -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(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; diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs index e40dca9bf6e..1270a2418f3 100644 --- a/crates/engine/src/display_color_tests.rs +++ b/crates/engine/src/display_color_tests.rs @@ -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:?}"); + } +} diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 55556c53d86..ed56e500231 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -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 { } 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> = 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 { diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index 50a8a49e622..81d17c28c2c 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -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 diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 6bf834ae14f..f0541136ade 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -881,6 +881,32 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { 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 { + 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 { 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 { ), 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(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 344141204b1..cda3de84d43 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -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 { @@ -212,7 +213,7 @@ fn fade(s: &mut Session, p: &Value) -> Result { 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(()) })?; diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 624a236200e..f7c2f23e834 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -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::(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); + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7470e782311..7689f396792 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -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> { /// 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, 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 { } }; 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 { } } 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 { "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 ), diff --git a/crates/engine/src/file_cmds/batch_naming.rs b/crates/engine/src/file_cmds/batch_naming.rs new file mode 100644 index 00000000000..2a32f71328f --- /dev/null +++ b/crates/engine/src/file_cmds/batch_naming.rs @@ -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) -> 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>> { + 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>> { + 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"); + } +} diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 73c09020a52..99ddce15de5 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -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]); +} diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index df925caf76a..68e617bc8cd 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -100,7 +100,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { 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 { } _ => 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(); diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index aaab22fde24..e5450b2339e 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -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> 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), } diff --git a/crates/engine/src/history_select_cmds.rs b/crates/engine/src/history_select_cmds.rs new file mode 100644 index 00000000000..e05a859a5a5 --- /dev/null +++ b/crates/engine/src/history_select_cmds.rs @@ -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) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +fn select_state(s: &mut Session, p: &Value) -> Result { + 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 { + 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, Option, Vec, Vec, Vec); + + /// 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 = 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()); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d0b2a72e981..22c3d3a96f7 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -38,7 +38,7 @@ pub fn document(d: &DocState) -> Value { "isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::>(), "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::>(), + "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected, Some(&doc.metadata))).collect::>(), "history": d.history.entries(), "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "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))]; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9c8cb9cdd01..dc43f4d7941 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -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 { 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; diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index a40b712c814..01269228182 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -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); diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index 9a42778de2d..d08955fdf66 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -213,6 +213,66 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::>(), "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 { + 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 = 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::>()}); + 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 { vec![CommandSpec { id: CMD, diff --git a/crates/engine/src/layer_copy_cmds/tests.rs b/crates/engine/src/layer_copy_cmds/tests.rs index 476df187901..a81ebbdc470 100644 --- a/crates/engine/src/layer_copy_cmds/tests.rs +++ b/crates/engine/src/layer_copy_cmds/tests.rs @@ -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 = 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); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 85cef3665dc..21e9e2caaca 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -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 { 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 58a89742980..ff3cde6e4cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// 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, + /// 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, + /// 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 { if index >= self.docs.len() { return None; diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index ca814e6d4cb..a6f4cefd38a 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -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 { 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 { }; 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 { 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::>(); + 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}"); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1dc82005db1..5e728cb5461 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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. diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 37b375cea29..e6bff8eb6b0 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -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]> = diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 4186c167edd..83cb16a838d 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -299,7 +299,7 @@ fn decode_tip_max(bytes: &[u8], max_side: u32) -> Result { } let data: Vec = 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 { 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)] diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 3fd492b3d88..1d786c61fed 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -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, @@ -214,8 +216,12 @@ fn clone_sample( which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping, + wrap: bool, cmd: &str, ) -> Result { + 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 { + 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 { 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 { 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 { + 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 { 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 { 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 { 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, diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs new file mode 100644 index 00000000000..f31542fb5ac --- /dev/null +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -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 { + 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) -> Result { + 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, + work: &mut u64, + ) -> Result { + 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, + depth: Vec, + // 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) { + 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) { + 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, surf: &Surface, which: SampleLayers, rect: Rect, cmd: &str) -> Result { + 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) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index ca58ff290bd..39950968ec5 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -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", ¶ms).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", ¶ms).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}"); + } + } +} diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index c59d1a4530a..8efb8d7b943 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -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 { + 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| x.as_ref().map(|s| s.read_region(area)); + assert_eq!(read(&got), read(&want), "case {case}: {m}, anti-alias {aa}, {pts:?}"); + } + } } diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e108c4d7be6..295b845df0d 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -862,12 +862,28 @@ pub fn contents_of(doc: &Document, id: LayerId) -> Result<(String, Arc>) 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 { 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 { @@ -1216,7 +1232,14 @@ pub fn specs() -> Vec { 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 })) diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index a6cdbf48d5b..a764712c9fa 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -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"); diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index b04aae7a847..651db7cdc00 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -397,7 +397,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { } 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(), ¶ms, brush.as_ref()) })?; @@ -416,7 +417,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs index 72b0c783b76..7844124bdb5 100644 --- a/crates/engine/src/swatch_cmds.rs +++ b/crates/engine/src/swatch_cmds.rs @@ -600,8 +600,18 @@ fn import(s: &mut Session, p: &Value) -> Result { // Names stay unique across the library (agents address swatches by name). let mut next: std::collections::HashMap = std::collections::HashMap::new(); let mut taken: std::collections::HashSet = 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 = std::collections::HashMap::new(); + let mut group_taken: std::collections::HashSet = 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. diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 836b843971f..2a3e8e7ddb0 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -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 = 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::>().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] diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 1e7556b1d45..efa2d791cce 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -509,20 +509,30 @@ fn update_all(s: &mut Session) -> Result { Ok(json!({"updated": n})) } -fn installed_families() -> Vec { - 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 { - let have = installed_families(); + let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let mut out: Vec = 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 { /// Font families used by a type layer that the font database cannot supply. pub fn missing_fonts_in_layer(t: &TextLayer) -> Vec { - let have = installed_families(); + let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); let mut out: Vec = 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, 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; diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index c0c091b10c9..9f604eed1bc 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -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::::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 { + 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 = [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 = 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)); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index a26219033b5..51d6183baa7 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -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, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index d76a496fb0a..82480e05c01 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -46,6 +46,9 @@ pub struct DocM { pub layers: Vec, pub channels: Vec, 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, pub metadata: MetadataM, pub global_light: GlobalLight, diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index ef840cfc80e..8d5641d9cdd 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -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); diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index cc396d8ba0b..2149aaa4124 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -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); + } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 730de701452..223d4ed9832 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -552,17 +552,18 @@ fn field_reaches(progs: &[fx::MapProgram]) -> HashMap { 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(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option) { 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(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option(device: &wgpu::Device, watched: bool, f: impl FnOnce() -> Result) -> Result { + // 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 { - 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 { - 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)] diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/device_loss.rs index 5106c97a728..689a7872809 100644 --- a/crates/gpu/tests/device_loss.rs +++ b/crates/gpu/tests/device_loss.rs @@ -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()); +} diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index eaf77e864fe..6634735ef0f 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -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 { + 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 { 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 { + 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. diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index dfc7e46c9ee..9db3d4799f2 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -79,6 +79,32 @@ struct Ex { smart: SmartOut, /// The document's patterns, which Pattern Fill layers are rendered from. patterns: Vec, + /// The layer written as Photoshop's Background (see [`psd_background`]). + background: Option, +} + +/// 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 { + 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)>, key: &[u8; 4], data: Vec, 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) { + fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec) { + 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)> = 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) { @@ -384,10 +424,11 @@ impl Ex { fn record(&mut self, l: &Layer, extra: Vec, 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::>(); + 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); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 1e77cdaa6a3..444c2d6e5ba 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -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> { + 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 = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]).collect(); + let planes: Vec>> = 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>> = crate::pixels::par_map(ids.to_vec(), |id| self.channel_plane_decoded(rec, id)); + let mut planes: Vec>> = 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) { // 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)); } } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 0aca0fe8826..8a5810e9225 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -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 = 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)), diff --git a/crates/io/tests/psd_roundtrip_bytes.rs b/crates/io/tests/psd_roundtrip_bytes.rs new file mode 100644 index 00000000000..dccd57856d5 --- /dev/null +++ b/crates/io/tests/psd_roundtrip_bytes.rs @@ -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 = 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}"); + } +} diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 651d74d53cd..6502079f203 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -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); +} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 913ab2f39e9..56e51bf9f98 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -127,7 +127,7 @@ fn exif() -> Vec { } /// #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] diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 2a82edb4177..a20b841c174 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -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] { diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index feb639c8711..b8d27775f93 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -30,6 +30,10 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { for channel in doc.channels.iter().chain(&doc.quick_mask) { visit(&channel.surface); } + // A pattern fill or layer style copies its pattern into the document. + for pattern in &doc.patterns { + visit(&pattern.surface); + } if let Some(selection) = &doc.selection { visit(selection); } diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 89c0abebd63..5f59aafad18 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -234,7 +234,7 @@ impl BrushContext { /// [`rasterize`](Self::rasterize); with `depth`, Wet Edges is left out of `out` and each /// painted pixel's depth inside the dab (`1 − rn`) goes to `depth`, for the stroke to apply it. - fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { + pub fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { let (w, h) = (rect.width() as usize, rect.height() as usize); out.clear(); out.resize(w * h, 0.0); @@ -394,7 +394,7 @@ impl BrushContext { /// [`stroke_mask`](Self::stroke_mask) with Wet Edges from the stroke's depth at the pixel /// (the deepest any dab reaches it, see [`CoverageMap::depth`]). #[inline] - fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { + pub fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { let b = &self.brush; let mut m = match depth { Some(dp) => c * wet_factor(1.0 - dp), diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 6152411c91c..38ca38410e7 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -6,11 +6,14 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A grayscale bitmap, row-major, 1 = full. +/// +/// The samples are shared: cloning a tile (brush settings are cloned on every pointer event of a +/// stroke, #4534) copies a pointer, not a sampled tip that may be thousands of pixels across. #[derive(Clone, Debug, PartialEq, Eq)] pub struct GrayTile { pub width: u32, pub height: u32, - pub data: Vec, + pub data: std::sync::Arc<[u16]>, } impl GrayTile { @@ -22,7 +25,7 @@ impl GrayTile { data.push(q16(f(x, y))); } } - Self { width, height, data } + Self { width, height, data: data.into() } } /// Build from 0..1 floats (`width × height`). pub fn from_f32(width: u32, height: u32, v: &[f32]) -> Self { @@ -47,7 +50,7 @@ impl GrayTile { pub fn preview(&self, max_side: u32) -> GrayTile { let q8 = |v: u32| ((v + 128) / 257 * 257) as u16; if !self.is_valid() { - return GrayTile { width: 1, height: 1, data: vec![0] }; + return GrayTile { width: 1, height: 1, data: vec![0].into() }; } let (w, h) = (self.width as usize, self.height as usize); let max = max_side.max(1) as usize; @@ -64,7 +67,8 @@ impl GrayTile { } } } - let data = sum.iter().zip(&cnt).map(|(s, c)| q8(if *c > 0 { (*s / u64::from(*c)) as u32 } else { 0 })).collect(); + let data: Vec = sum.iter().zip(&cnt).map(|(s, c)| q8(if *c > 0 { (*s / u64::from(*c)) as u32 } else { 0 })).collect(); + let data = data.into(); GrayTile { width: nw as u32, height: nh as u32, data } } } @@ -141,7 +145,7 @@ struct GrayTileIn { impl Serialize for GrayTile { fn serialize(&self, s: S) -> Result { let mut bytes = Vec::with_capacity(self.data.len() * 2); - for v in &self.data { + for v in self.data.iter() { bytes.extend_from_slice(&v.to_le_bytes()); } let enc = b64_encode(&bytes); @@ -164,7 +168,7 @@ impl<'de> Deserialize<'de> for GrayTile { if n == 0 || data.len() != n { return Err(D::Error::custom(format!("tile data has {} samples, expected {}×{}", data.len(), t.width, t.height))); } - Ok(GrayTile { width: t.width, height: t.height, data }) + Ok(GrayTile { width: t.width, height: t.height, data: data.into() }) } } @@ -300,6 +304,20 @@ impl PatternImage { mod tests { use super::*; + /// Cloning a tile (and the brush settings holding it, on every pointer event of a stroke) + /// shares its samples instead of copying them (#4534). + #[test] + fn clones_share_their_samples() { + let t = GrayTile::from_fn(64, 32, |x, y| ((x + y) % 7) as f32 / 6.0); + let c = t.clone(); + assert!(std::sync::Arc::ptr_eq(&t.data, &c.data)); + assert_eq!(t, c); + let brush = crate::brush::BrushSettings { tip: crate::brush::TipShape::Sampled(t.clone()), ..Default::default() }; + let copy = brush.clone(); + let crate::brush::TipShape::Sampled(ct) = ©.tip else { panic!("sampled") }; + assert!(std::sync::Arc::ptr_eq(&t.data, &ct.data), "the settings' clone shares the tip"); + } + #[test] fn base64_round_trip() { for n in 0..10 { @@ -315,7 +333,7 @@ mod tests { let back: GrayTile = serde_json::from_str(&j).unwrap(); assert_eq!(back, t); let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap(); - assert_eq!(f.data, vec![0, 65535]); + assert_eq!(f.data, vec![0, 65535].into()); assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); } @@ -329,8 +347,8 @@ mod tests { assert!(p.data.iter().all(|v| v % 257 == 0), "8-bit levels"); // Small tiles keep their size; invalid ones never panic. assert_eq!(GrayTile::from_fn(5, 3, |_, _| 0.5).preview(96).width, 5); - assert!(GrayTile { width: 4, height: 4, data: vec![1] }.preview(96).is_valid()); - assert!(GrayTile { width: 0, height: 0, data: vec![] }.preview(0).is_valid()); + assert!(GrayTile { width: 4, height: 4, data: vec![1].into() }.preview(96).is_valid()); + assert!(GrayTile { width: 0, height: 0, data: vec![].into() }.preview(0).is_valid()); } #[test] diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index d47408d28cc..7ad2296fbc0 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -8,7 +8,7 @@ use std::io::{Read, Write}; use crate::error::{PsdError, Result}; use crate::header::{Version, row_bytes}; -use crate::io::{Reader, WriteExt}; +use crate::io::Reader; /// Maximum number of decoded bytes a single decode call may produce: 8 GiB on /// 64-bit targets, 2 GiB elsewhere (the same budget as `photocraft-codecs`' @@ -345,21 +345,62 @@ fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { let rows = layout.rows()?; let rb = layout.row_bytes(); let cs = layout.count_size(); - let mut out = vec![0u8; rows * cs]; - let mut enc = Vec::new(); - for row in 0..rows { - let start = enc.len(); - packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); - let n = enc.len() - start; - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); + if rows == 0 { + return Ok(Vec::new()); + } + // Rows are independent, so they pack in parallel chunks and concatenate in order. + let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; + let chunk_rows = rows.div_ceil(threads).max(1); + struct Chunk { + counts: Vec, + packed: Vec, + } + let pack_chunk = |first: usize, count: usize| -> Result { + let mut chunk = Chunk { counts: vec![0u8; count * cs], packed: Vec::new() }; + for row in first..first + count { + let start = chunk.packed.len(); + packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut chunk.packed); + let n = chunk.packed.len() - start; + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + chunk.counts[(row - first) * 2..(row - first) * 2 + 2].copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + chunk.counts[(row - first) * 4..(row - first) * 4 + 4].copy_from_slice(&n.to_be_bytes()); + } } + Ok(chunk) + }; + let chunk_list: Vec = if threads == 1 { + vec![pack_chunk(0, rows)?] + } else { + std::thread::scope(|scope| -> Result> { + let handles: Vec<_> = (0..threads) + .map(|t| { + let first = t * chunk_rows; + let count = chunk_rows.min(rows.saturating_sub(first)); + scope.spawn(move || pack_chunk(first, count)) + }) + .collect(); + // Join every worker first, then surface the first error like the sequential + // path would instead of aborting the scope with a thread panic. + let mut all = Vec::with_capacity(threads); + for h in handles { + all.push(h.join().map_err(|_| PsdError::LimitExceeded("an RLE worker panicked"))??); + } + Ok(all) + })? + }; + let packed_total: usize = chunk_list.iter().map(|c| c.packed.len()).sum(); + let mut out = vec![0u8; rows * cs + packed_total]; + let mut counts_at = 0usize; + let mut packed_at = rows * cs; + for chunk in &chunk_list { + out[counts_at..counts_at + chunk.counts.len()].copy_from_slice(&chunk.counts); + counts_at += chunk.counts.len(); + out[packed_at..packed_at + chunk.packed.len()].copy_from_slice(&chunk.packed); + packed_at += chunk.packed.len(); } - out.put(&enc); Ok(out) } @@ -830,3 +871,79 @@ mod tests { assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err()); } } + +#[cfg(test)] +mod parallel_rle_equivalence { + use super::*; + + /// The parallel row packer's output must be byte-equal to a straightforward sequential + /// encoding of the same plane, across depths, row counts around the chunking and data + /// shapes (runs, noise, all-default). + #[test] + fn parallel_rle_matches_sequential() { + let mut data: u64 = 0x9e37_79b9_7f4a_7c15; + let mut next = move || { + data ^= data << 13; + data ^= data >> 7; + data ^= data << 17; + data + }; + let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; + for (rows, rb, version) in [ + (0usize, 4usize, Version::Psd), + (1, 4, Version::Psd), + (7, 4, Version::Psb), + (64, 4, Version::Psd), + (65, 4, Version::Psb), // chunk boundary + (513, 4, Version::Psd), // more rows than threads on most runners + (2049, 4, Version::Psb), + (2, 4, Version::Psd), // fewer rows than threads + (33, 3000, Version::Psd), + (31, 20000, Version::Psb), + ] { + let pattern: Vec = match rows % 3 { + 0 => vec![7u8; rows * rb], // long runs + 1 => (0..rows * rb).map(|i| (i * 31 % 251) as u8).collect(), // noise-ish + _ => vec![0u8; rows * rb], // all default + }; + let pattern = if pattern.len() != rows * rb { vec![7u8; rows * rb] } else { pattern }; + let mut data = pattern.clone(); + if rows % 4 == 0 { + // sprinkle runs into the noise + for chunk in data.chunks_mut(97) { + if let Some(f) = chunk.first_mut() { + *f = 200; + } + } + } + let _ = next(); + let layout = layout_for(rows, rb, version); + let parallel = encode_rle(&data, &layout).expect("parallel encode"); + let sequential = encode_rle_sequential(&data, &layout).expect("sequential encode"); + assert_eq!(parallel, sequential, "rows {rows} rb {rb}"); + } + } + + /// The pre-parallel encoder, kept as the oracle. + fn encode_rle_sequential(decoded: &[u8], layout: &PlaneLayout) -> Result> { + let rows = layout.rows()?; + let rb = layout.row_bytes(); + let cs = layout.count_size(); + let mut out = vec![0u8; rows * cs]; + let mut enc = Vec::new(); + for row in 0..rows { + let start = enc.len(); + packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); + let n = enc.len() - start; + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); + } + } + out.extend_from_slice(&enc); + Ok(out) + } +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 330940e7a48..1e86665ad81 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -112,13 +112,30 @@ impl PsdFile { if s.remaining() >= 4 { let n = s.u32()?; global_layer_mask = Some(GlobalLayerMask { data: s.bytes_u64(u64::from(n))?.to_vec() }); - let mut candidate_seen = layer_info.is_some(); + let target_key = match header.depth { + 16 => Some(b"Lr16"), + 32 => Some(b"Lr32"), + _ => None, + }; + let mut candidate_seen = match target_key { + Some(_) => false, + None => layer_info.as_ref().is_some_and(|i| !i.layers.is_empty()), + }; let (blocks, trailing) = read_blocks_with(&mut s, header.version, 4, |index, signature, key, data, padding| { - if candidate_seen || !matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { + let is_candidate = match target_key { + Some(k) => &key == k, + None => matches!(&key, b"Layr" | b"Lr16" | b"Lr32"), + }; + if candidate_seen || !is_candidate { return false; } candidate_seen = true; - let Ok(info) = LayerInfo::read_body(data, header.version) else { return false }; + let Ok(info) = LayerInfo::read_body(data, header.version) else { + if target_key.is_some() { + layer_info = None; + } + return false; + }; layer_info = Some(info); layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature, key, padding: padding.clone() }; true @@ -389,4 +406,32 @@ mod write_tests { } } } + + #[test] + fn global_layers_prioritized_over_compatibility_section() { + for version in [Version::Psd, Version::Psb] { + for depth in [16, 32] { + let file = testgen::layered(version, ColorMode::Rgb, depth, Compression::Raw); + let key = if depth == 16 { *b"Lr16" } else { *b"Lr32" }; + let mut lr_data = Vec::new(); + file.layer_info.as_ref().unwrap().write_body(&mut lr_data, version).unwrap(); + + // Photoshop writes an empty (or 8-bit compatibility) Layer Info section + // alongside the global Lr16/Lr32 block (#4928). + let mut compat_file = file.clone(); + compat_file.layer_info = + Some(LayerInfo { layers: vec![LayerRecord { name: b"CompatibilityLayer".to_vec(), ..Default::default() }], ..Default::default() }); + compat_file.layer_info_placement = LayerInfoPlacement::Section; + compat_file.global_blocks.insert(0, TaggedBlock::new(key, lr_data)); + + let bytes = compat_file.to_bytes().unwrap(); + let parsed = PsdFile::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.layers().len(), file.layers().len()); + assert_ne!(parsed.layers()[0].name(), "CompatibilityLayer"); + assert_eq!(parsed.layers()[0].name(), file.layers()[0].name()); + assert!(matches!(parsed.layer_info_placement, LayerInfoPlacement::GlobalBlock { key: k, .. } if k == key)); + } + } + } } diff --git a/crates/raw/README.md b/crates/raw/README.md index 232ef83b3bc..b8c75ebff6a 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -45,6 +45,10 @@ Implemented only from public specifications, papers and observation of files: by observation of sample files (bit packing, page layout, sample justification; for RAF the three sample storages and the reversed order of the X-Trans / Bayer layout records, each verified by developing with every candidate phase against the camera's preview). +* Panasonic RW2 RawFormat 3/4 (vendor-compressed): the fixed-rate 16 bytes per 14 pixels, the + predictor groups with per-parity predictors, the 2/4/8-bit field widths, the page-permuted + downward bit order and the black level sitting 15 counts above the recorded tags were all + established by observation of sample files. * Demosaicing: Malvar, He & Cutler (ICASSP 2004); Hirakawa & Parks, "Adaptive homogeneity-directed demosaicing" (IEEE TIP 2005); for X-Trans and other non-Bayer CFAs, gradient-weighted green interpolation (Lu & Tan, "Color filter array demosaicking: new method @@ -71,8 +75,9 @@ camera colour tables were copied. | Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files are unsupported (preview fallback) | | Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits, the curve's 512 black level when no BlackLevel tag is written (the first-generation bodies, e.g. the ILCE-7) | | Panasonic / Leica RW2, RawFormat 5 (12- and 14-bit packed) | Decoded, with PanasonicRaw black / white / WB / sensor borders | +| Panasonic / Leica RW2, RawFormat 3 and 4 (vendor-compressed, 12-bit) | Decoded; the black level is read 15 counts above the recorded tag; PanasonicRaw white / WB / sensor borders | | Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop | -| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | +| Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 6 and newer, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f8304b3cfa0..0bccf8ffacb 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -32,9 +32,9 @@ //! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), -//! Panasonic RW2 (RawFormat 5), uncompressed Olympus ORF and uncompressed -//! Fujifilm RAF (Bayer and X-Trans). Everything else reports -//! [`RawError::Unsupported`]. +//! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed), +//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and +//! X-Trans). Everything else reports [`RawError::Unsupported`]. //! //! The crate is standalone (no workspace dependencies), does no I/O, builds for //! `wasm32-unknown-unknown` and never panics on hostile input: sizes are diff --git a/crates/raw/src/rw2.rs b/crates/raw/src/rw2.rs index 231cd0971b9..c50d32c09c4 100644 --- a/crates/raw/src/rw2.rs +++ b/crates/raw/src/rw2.rs @@ -1,5 +1,6 @@ -//! Panasonic / Leica RW2 with the bit-packed sensor layout (PanasonicRaw -//! RawFormat 5, written by recent Lumix bodies). +//! Panasonic / Leica RW2: the bit-packed stream of recent Lumix bodies +//! (PanasonicRaw RawFormat 5) and the vendor-compressed stream of the earlier +//! ones (RawFormat 3 and 4). //! //! The container is TIFF-like (`IIU\0`); IFD0 holds Panasonic's own tags, //! whose ids and meanings are public (ExifTool's PanasonicRaw table): @@ -9,7 +10,7 @@ //! WB{Red,Green,Blue}Level (0x24–0x26), RawFormat (0x2D) and RawDataOffset //! (0x118). //! -//! The packing was established by observation of sample files: +//! RawFormat 5 — the packing was established by observation of sample files: //! //! * From RawDataOffset the data is stored in pages of 0x4000 bytes, each //! page split at 0x1FF8 with its second part first (the logical page is @@ -19,8 +20,26 @@ //! the least significant bit up, the remaining top bits unused; rows are //! whole blocks. //! -//! The older RawFormat 4 (and earlier) store vendor-compressed data, which is -//! not decoded. +//! RawFormat 3 and 4 — the vendor-compressed layout, also established by +//! observation of sample files (verified by decoding whole frames against the +//! camera's embedded JPEG and the recorded levels): +//! +//! * The payload at RawDataOffset is a fixed-rate stream: 16 bytes per 14 +//! pixels, so a row occupies `width / 14 * 16` bytes and rows are +//! byte-aligned. Bits are consumed down from the top of each 0x4000-byte +//! page, through the same page split as RawFormat 5 (`(i + 0x4000 - 0x2008) +//! % 0x4000`), reading fields LSB-first from the little-endian 16-bit word +//! the cursor points at. +//! * Pixels arrive in groups of 14 with two predictors carried over from one +//! group to the next (even and odd columns). Every third pixel carries a +//! 2-bit scale selector `sh` (0, 1, 2 or 4). The first value of each parity +//! is absolute (8 + 4 bits); the rest are 8-bit deltas around 0x80 scaled +//! by `sh`, wrapping in the low `sh` bits. +//! * The decoded samples sit 15 counts above the recorded BlackLevel tags +//! (measured on the darkest pixels of sample files), so 15 is added when +//! they are read. +//! +//! Every other RawFormat value is not decoded. use crate::error::{RawError, Result}; use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; @@ -44,6 +63,8 @@ const RAW_DATA_OFFSET: u16 = 0x0118; /// Page size and split point of the stored stream (observed). const PAGE: usize = 0x4000; const SPLIT: usize = 0x1FF8; +/// Byte split of the vendor-compressed stream (observed). +const COMPRESSED_SPLIT: usize = 0x2008; /// Physical offset (relative to RawDataOffset) of logical byte `i`. #[inline] @@ -75,34 +96,13 @@ pub(crate) fn unpack_block(b: &[u8; 16], bits: u32, out: &mut [u16]) { } } -pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { - let ifd = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("RW2 has no IFD0"))?; - let raw_format = t.tag_uint(&ifd, RAW_FORMAT); - if raw_format != Some(5) { - let f = raw_format.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()); - return Err(RawError::unsupported(format!("Panasonic RW2 raw format {f} (vendor-compressed) is not decoded yet"))); - } - let width = t.tag_uint(&ifd, SENSOR_WIDTH).ok_or_else(|| RawError::malformed("RW2 has no sensor width"))? as usize; - let height = t.tag_uint(&ifd, SENSOR_HEIGHT).ok_or_else(|| RawError::malformed("RW2 has no sensor height"))? as usize; - let bits = t.tag_uint(&ifd, BITS_PER_SAMPLE).unwrap_or(12); +/// Reads the bit-packed RawFormat 5 stream (`src` starts at RawDataOffset). +fn decode_packed(src: &[u8], width: usize, height: usize, bits: u32) -> Result> { let ppb = pixels_per_block(bits).ok_or_else(|| RawError::unsupported(format!("Panasonic RW2 with {bits}-bit samples")))?; if !width.is_multiple_of(ppb) { return Err(RawError::unsupported(format!("Panasonic RW2 {width} pixels wide (not whole {ppb}-pixel blocks)"))); } - limits.check(width as u64, height as u64, 2)?; - let cfa = match t.tag_uint(&ifd, CFA_PATTERN) { - Some(1) => "RGGB", - Some(2) => "GRBG", - Some(3) => "GBRG", - Some(4) => "BGGR", - _ => return Err(RawError::unsupported("Panasonic RW2 without a CFA pattern")), - }; - let cfa = Cfa::bayer(cfa).ok_or_else(|| RawError::malformed("bad CFA"))?; - let offset = - t.tag_uint(&ifd, RAW_DATA_OFFSET).or_else(|| t.tag_uint(&ifd, tag::STRIP_OFFSETS)).ok_or_else(|| RawError::malformed("RW2 has no raw data offset"))? - as usize; let row_bytes = width / ppb * 16; - let src = t.data.get(offset..).ok_or_else(|| RawError::malformed("RW2 raw data lies outside the file"))?; let need = row_bytes * height; // Whole pages are stored; tolerate a short final page (its missing bytes read as 0). if src.len() < need.saturating_sub(PAGE) { @@ -124,11 +124,138 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { } } }); + Ok(data) +} + +/// LSB-first bit cursor of the vendor-compressed stream: it walks down from +/// the top of each 0x4000-byte page (addressed through `COMPRESSED_SPLIT`) +/// and refills by advancing one page. +struct Bits<'a> { + buf: &'a [u8], + base: usize, + pos: usize, + nbits: u32, +} + +impl<'a> Bits<'a> { + fn new(buf: &'a [u8], base: usize) -> Self { + Bits { buf, base, pos: 0, nbits: 0 } + } + + #[inline] + fn get(&mut self, num: u32) -> u32 { + if num > self.nbits { + self.nbits += PAGE as u32 * 8; + self.pos += PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + PAGE - COMPRESSED_SPLIT) % PAGE; + let at = self.base + at + self.pos - PAGE; + self.nbits -= num; + // Out-of-range reads (a page short at the end of the file) read as 0. + let lo = u32::from(self.buf.get(at).copied().unwrap_or(0)); + let hi = u32::from(self.buf.get(at + 1).copied().unwrap_or(0)); + ((lo | (hi << 8)) >> (self.nbits & 7)) & ((1u32 << num) - 1) + } +} + +/// Decodes one row of the vendor-compressed stream. +fn compressed_row(buf: &[u8], row_bytes: usize, row: usize, out: &mut [u16]) { + let skip = row_bytes * row; + let mut bits = Bits::new(buf, skip / PAGE * PAGE); + for _ in 0..(skip % PAGE) { + bits.get(8); + } + let mut sh: i32 = 0; + for group in out.as_chunks_mut::<14>().0 { + let mut pred = [0i32; 2]; + let mut nonz = [0i32; 2]; + for (i, px) in group.iter_mut().enumerate() { + if i % 3 == 2 { + sh = 4 >> (3 - bits.get(2)); + } + let p = i & 1; + if nonz[p] != 0 { + let j = bits.get(8) as i32; + if j != 0 { + pred[p] -= 0x80 << sh; + if pred[p] < 0 || sh == 4 { + pred[p] &= !(-1 << sh); + } + pred[p] += j << sh; + } + } else { + nonz[p] = bits.get(8) as i32; + if nonz[p] != 0 || i > 11 { + pred[p] = (nonz[p] << 4) | bits.get(4) as i32; + } + } + *px = pred[p] as u16; + } + } +} + +/// Reads the vendor-compressed RawFormat 3/4 stream (`src` starts at +/// RawDataOffset). +fn decode_compressed(src: &[u8], width: usize, height: usize, bits: u32) -> Result> { + if bits != 12 { + return Err(RawError::unsupported(format!("Panasonic RW2 with {bits}-bit compressed samples"))); + } + if !width.is_multiple_of(14) { + return Err(RawError::unsupported(format!("Panasonic RW2 {width} pixels wide (not whole 14-pixel blocks)"))); + } + let row_bytes = width / 14 * 16; + let need = row_bytes * height; + // Whole pages are stored; tolerate a short final page (its missing bytes read as 0). + if src.len() < need.saturating_sub(PAGE) { + return Err(RawError::malformed("RW2 raw data is truncated")); + } + let mut data = vec![0u16; width * height]; + let band = par::band_rows(width); + par::chunks_mut(&mut data, band * width, |i, chunk| { + for (r, out) in chunk.chunks_exact_mut(width).enumerate() { + compressed_row(src, row_bytes, i * band + r, out); + } + }); + Ok(data) +} + +pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { + let ifd = t.ifd_at(t.first_ifd, 0).ok_or_else(|| RawError::malformed("RW2 has no IFD0"))?; + let raw_format = t.tag_uint(&ifd, RAW_FORMAT); + let compressed = match raw_format { + Some(5) => false, + Some(3 | 4) => true, + other => { + let f = other.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()); + return Err(RawError::unsupported(format!("Panasonic RW2 raw format {f} (vendor-compressed) is not decoded yet"))); + } + }; + let width = t.tag_uint(&ifd, SENSOR_WIDTH).ok_or_else(|| RawError::malformed("RW2 has no sensor width"))? as usize; + let height = t.tag_uint(&ifd, SENSOR_HEIGHT).ok_or_else(|| RawError::malformed("RW2 has no sensor height"))? as usize; + limits.check(width as u64, height as u64, 2)?; + let bits = t.tag_uint(&ifd, BITS_PER_SAMPLE).unwrap_or(12); + let cfa = match t.tag_uint(&ifd, CFA_PATTERN) { + Some(1) => "RGGB", + Some(2) => "GRBG", + Some(3) => "GBRG", + Some(4) => "BGGR", + _ => return Err(RawError::unsupported("Panasonic RW2 without a CFA pattern")), + }; + let cfa = Cfa::bayer(cfa).ok_or_else(|| RawError::malformed("bad CFA"))?; + let offset = + t.tag_uint(&ifd, RAW_DATA_OFFSET).or_else(|| t.tag_uint(&ifd, tag::STRIP_OFFSETS)).ok_or_else(|| RawError::malformed("RW2 has no raw data offset"))? + as usize; + let src = t.data.get(offset..).ok_or_else(|| RawError::malformed("RW2 raw data lies outside the file"))?; + let data = if compressed { decode_compressed(src, width, height, bits)? } else { decode_packed(src, width, height, bits)? }; let full = Rect::new(0, 0, width, height); let level = |tg: u16| t.tag_uint(&ifd, tg).map(f64::from); let mut warnings = Vec::new(); - let black = match BLACK_LEVEL.map(level) { + // The compressed variants record a black level 15 counts below the decoded black. + let black_offset = if compressed { 15.0 } else { 0.0 }; + let black_level = |tg: u16| t.tag_uint(&ifd, tg).map(|v| f64::from(v) + black_offset); + let black = match BLACK_LEVEL.map(black_level) { [Some(r), Some(g), Some(b)] => { let v = cfa.phase(0, 0).map(|c| [r, g, b][usize::from(c).min(2)] as f32); BlackLevels { rows: 2, cols: 2, values: v.to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } @@ -204,4 +331,76 @@ mod tests { assert_eq!(&out[..9], &[0x3FFF; 9]); assert_eq!(&out[9..], &[0; 3]); } + + #[test] + fn compressed_bits_walk_down_pages() { + // A fresh cursor reads the word at 0x2007 first (the top of the first + // page under the compressed split), then walks down. + let mut buf = vec![0u8; 0x4000]; + buf[0x2007] = 0xAC; + buf[0x2008] = 0x03; + buf[0x2006] = 0x11; + buf[0x2017] = 0x55; + // A 4-bit field starts in the high nibble of the top word. + let mut b = Bits::new(&buf, 0); + assert_eq!(b.get(4), 0xA); + let mut b = Bits::new(&buf, 0); + assert_eq!(b.get(8), 0xAC); + assert_eq!(b.get(8), 0x11); + // 16 bytes later the cursor wraps to the end of the next 16-byte run. + for _ in 0..14 { + b.get(8); + } + assert_eq!(b.get(8), 0x55); + } + + #[test] + fn compressed_group_walks_predictors() { + // One 14-pixel group: both parities start absolute, a delta of 0x80 + // keeps their value, and the scale selector is written before the + // delta of every third pixel. The test writer mirrors `Bits` so the + // fields land where the decoder reads them. + let mut buf = vec![0u8; 0x4000]; + { + let mut w = TestWriter { buf: &mut buf, pos: 0, nbits: 0 }; + w.put(0x10, 8); + w.put(0x5, 4); + w.put(0x2A, 8); + w.put(0xA, 4); + for i in 2..14 { + if i % 3 == 2 { + w.put(0, 2); + } + w.put(0x80, 8); + } + } + let mut out = [0u16; 14]; + compressed_row(&buf, 16, 0, &mut out); + assert_eq!(out, [0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA, 0x105, 0x2AA]); + } + + /// Mirror of [`Bits`] for unit tests: deposits fields where the reader + /// will pick them up. + struct TestWriter<'a> { + buf: &'a mut [u8], + pos: usize, + nbits: u32, + } + + impl TestWriter<'_> { + fn put(&mut self, v: u32, num: u32) { + if num > self.nbits { + self.nbits += PAGE as u32 * 8; + self.pos += PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + PAGE - COMPRESSED_SPLIT) % PAGE; + let at = at + self.pos - PAGE; + self.nbits -= num; + let cur = u32::from(self.buf[at]) | (u32::from(self.buf[at + 1]) << 8); + let cur = cur | ((v & ((1 << num) - 1)) << (self.nbits & 7)); + self.buf[at] = cur as u8; + self.buf[at + 1] = (cur >> 8) as u8; + } + } } diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 823cd97ae63..2de64ae4bb6 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -1113,6 +1113,136 @@ pub fn rw2(width: usize, height: usize, data: &[u16], bits: u32) -> Vec { b } +/// Byte split of the vendor-compressed (RawFormat 3/4) stream (see `rw2.rs`). +const RW2_COMPRESSED_SPLIT: usize = 0x2008; + +/// Writer for the vendor-compressed RW2 stream: mirrors the decoder's cursor +/// (page addressing, downward field order) and deposits every field where the +/// reader will pick it up. +struct V4Writer<'a> { + buf: &'a mut [u8], + base: usize, + pos: usize, + nbits: u32, +} + +impl V4Writer<'_> { + /// Consumes one byte of the row seek (the decoder reads 8-bit fields). + fn skip_byte(&mut self) { + if 8 > self.nbits { + self.nbits += (RW2_PAGE as u32) * 8; + self.pos += RW2_PAGE; + } + self.nbits -= 8; + } + + fn put(&mut self, v: u32, num: u32) { + if num > self.nbits { + self.nbits += (RW2_PAGE as u32) * 8; + self.pos += RW2_PAGE; + } + let mut at = ((self.nbits - num) >> 3) as usize ^ 0x3FF0; + at = (at + RW2_PAGE - RW2_COMPRESSED_SPLIT) % RW2_PAGE; + let at = self.base + at + self.pos - RW2_PAGE; + self.nbits -= num; + let cur = u32::from(self.buf[at]) | (u32::from(self.buf[at + 1]) << 8); + let cur = cur | ((v & ((1 << num) - 1)) << (self.nbits & 7)); + self.buf[at] = cur as u8; + self.buf[at + 1] = (cur >> 8) as u8; + } + + /// Encodes one row as 14-pixel groups: the scale selector is always 0 and + /// the two first pixels of each group are absolute (8 + 4 bits); later + /// pixels use the 8-bit delta path. Callers keep values inside 16..4096 + /// and stepping by at most ±127 between pixels of the same parity. + fn row(&mut self, pixels: &[u16]) { + for group in pixels.as_chunks::<14>().0 { + let mut pred = [0i32; 2]; + let mut nonz = [0i32; 2]; + for (i, &px) in group.iter().enumerate() { + if i % 3 == 2 { + self.put(0, 2); // sh = 0 + } + let p = i & 1; + let t = i32::from(px); + if nonz[p] == 0 { + let nz = (t >> 4) & 0xFF; + self.put(nz as u32, 8); + if nz != 0 || i > 11 { + self.put((t & 0xF) as u32, 4); + pred[p] = t; + } else { + pred[p] = 0; + } + nonz[p] = nz; + } else { + let q = (pred[p] - 0x80).max(0); + let j = t - q; + assert!((1..=0xFF).contains(&j), "{t} is not encodable from {}", pred[p]); + self.put(j as u32, 8); + pred[p] = q + j; + } + assert_eq!(pred[p], t); + } + } + } +} + +/// The vendor-compressed (RawFormat 3/4) stream of a whole image; see +/// [`V4Writer::row`] for the value constraints. +pub fn rw2_v4_pack(data: &[u16], width: usize) -> Vec { + assert_eq!(width % 14, 0); + let height = data.len() / width; + assert_eq!(data.len(), width * height); + let row_bytes = width / 14 * 16; + let mut buf = vec![0u8; row_bytes * height + 2 * RW2_PAGE]; + for (row, pixels) in data.chunks_exact(width).enumerate() { + let skip = row_bytes * row; + let mut w = V4Writer { buf: &mut buf, base: skip / RW2_PAGE * RW2_PAGE, pos: 0, nbits: 0 }; + for _ in 0..(skip % RW2_PAGE) { + w.skip_byte(); + } + w.row(pixels); + } + buf +} + +/// A synthetic Panasonic RW2 (RawFormat 4, vendor-compressed) with an RGGB +/// sensor, a 2-pixel border on every side, black levels 128 / 129 / 130 and +/// WB levels 512 / 256 / 384; see [`V4Writer::row`] for the pixel constraints. +pub fn rw2_format4(width: usize, height: usize, data: &[u16]) -> Vec { + let mut t = TiffBuilder::default(); + let raw = t.blob(rw2_v4_pack(data, width)); + let ifd0 = t.ifd(vec![ + (0x0002, Val::Short(vec![width as u16])), + (0x0003, Val::Short(vec![height as u16])), + (0x0004, Val::Short(vec![2])), + (0x0005, Val::Short(vec![2])), + (0x0006, Val::Short(vec![height as u16 - 2])), + (0x0007, Val::Short(vec![width as u16 - 2])), + (0x0009, Val::Short(vec![1])), + (0x000A, Val::Short(vec![12])), + (0x000E, Val::Short(vec![4095])), + (0x000F, Val::Short(vec![4095])), + (0x0010, Val::Short(vec![4095])), + (0x001C, Val::Short(vec![128])), + (0x001D, Val::Short(vec![129])), + (0x001E, Val::Short(vec![130])), + (0x0024, Val::Short(vec![512])), + (0x0025, Val::Short(vec![256])), + (0x0026, Val::Short(vec![384])), + (0x002D, Val::Short(vec![4])), + (0x010F, Val::Ascii("Panasonic".into())), + (0x0110, Val::Ascii("DC-Synthetic".into())), + (0x0112, Val::Short(vec![1])), + (0x0118, Val::Blobs(vec![raw])), + ]); + t.chain = vec![ifd0]; + let mut b = t.build(); + b[2..4].copy_from_slice(b"U\0"); + b +} + // ---------------------------------------------------------------- Olympus ORF /// A little-endian IFD whose value offsets are relative to `base` (the IFD diff --git a/crates/raw/tests/vendor_codecs.rs b/crates/raw/tests/vendor_codecs.rs index e210c6f31b0..2d91a322a52 100644 --- a/crates/raw/tests/vendor_codecs.rs +++ b/crates/raw/tests/vendor_codecs.rs @@ -2,7 +2,7 @@ //! Sony compressed ARW (cRAW), Panasonic RW2 (RawFormat 5) and uncompressed //! Olympus ORF (with its maker-note preview). -use photocraft_raw::testgen::{craw_block, mosaic, orf, rw2, scene, sony_craw}; +use photocraft_raw::testgen::{craw_block, mosaic, orf, rw2, rw2_format4, scene, sony_craw}; use photocraft_raw::*; const CURVE: [u16; 4] = [8000, 10400, 12900, 14100]; @@ -176,13 +176,63 @@ fn patch_ifd0_short(b: &mut [u8], tag: u16, v: u16) { } #[test] -fn rw2_compressed_formats_fall_back() { +fn rw2_format4_round_trip_is_exact() { + let (w, h) = (42, 6); + let mut data = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + data.push(200 + 4 * (y as u16) + 3 * (x as u16)); + } + } + let b = rw2_format4(w, h, &data); + assert_eq!(identify(&b), Some(RawFormat::Rw2)); + let s = decode(&b, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (w, h)); + assert_eq!(s.data, data); + assert_eq!(s.cfa.as_ref().unwrap().phase(0, 0), [0, 1, 1, 2]); + // The compressed variants record a black level 15 counts below the black + // of the decoded samples. + assert_eq!(s.black.values, vec![143.0, 144.0, 144.0, 145.0]); + assert_eq!(s.white, [4095.0; 3]); + assert_eq!(s.camera_wb, Some([2.0, 1.0, 1.5])); + assert_eq!(s.crop, Rect::new(2, 2, w - 4, h - 4)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap(); + assert_eq!((d.width as usize, d.height as usize), (w - 4, h - 4)); +} + +#[test] +fn rw2_format4_crosses_pages() { + // 16 bytes a row: 1100 rows walk past several 0x4000-byte page boundaries. + let (w, h) = (14, 1100); + let mut data = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + data.push(300 + 16 * ((y % 8) as u16) + 3 * (x as u16)); + } + } + let b = rw2_format4(w, h, &data); + let s = decode(&b, &Limits::default()).unwrap(); + assert_eq!(s.data, data); +} + +#[test] +fn rw2_format4_rejects_other_block_widths() { + let (w, h) = (42, 2); + let data: Vec = (0..w * h).map(|i| 200 + (i % 14) as u16 * 3).collect(); + let mut b = rw2_format4(w, h, &data); + patch_ifd0_short(&mut b, 0x0002, 41); + assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Unsupported(_)))); +} + +#[test] +fn rw2_undecoded_formats_fall_back() { let data = vec![200u16; 20 * 4]; let mut b = rw2(20, 4, &data, 12); assert!(decode(&b, &Limits::default()).is_ok()); - patch_ifd0_short(&mut b, 0x002D, 4); + patch_ifd0_short(&mut b, 0x002D, 6); match decode(&b, &Limits::default()) { - Err(RawError::Unsupported(m)) => assert!(m.contains("raw format 4"), "{m}"), + Err(RawError::Unsupported(m)) => assert!(m.contains("raw format 6"), "{m}"), other => panic!("expected unsupported, got {other:?}"), } // Width not made of whole blocks. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d1df6bc20e0..5ce71e59ac9 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -584,10 +584,48 @@ pub fn face_count(bytes: &[u8]) -> usize { } } +// CamelCase style suffix of a name without a hyphen (`GmarketSansBold`). Only a capitalised word +// counts ("Daylight" keeps its "light"), and one after a vendor tag stays in a legacy family name +// (`ArialRoundedMTBold` is the family "Arial Rounded MT Bold"). +fn split_trailing_style(ps: &str) -> (&str, &str) { + let base = ps.trim_end_matches("MT").trim_end_matches("PS"); + let mut family = base; + for suffix in ["Italic", "Oblique"] { + if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) { + family = rest; + break; + } + } + for suffix in [ + "ExtraLight", + "UltraLight", + "ExtraBold", + "UltraBold", + "SemiBold", + "DemiBold", + "Hairline", + "Regular", + "Medium", + "Normal", + "Heavy", + "Black", + "Light", + "Thin", + "Bold", + "Demi", + ] { + if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) { + family = rest; + break; + } + } + if family.len() < base.len() && !family.ends_with("MT") { (family, ps.get(family.len()..).unwrap_or("")) } else { (ps, "") } +} + /// Heuristic PostScript-name split: `MyriadPro-BoldIt` → ("Myriad Pro", 700, italic). pub fn guess_from_postscript(ps: &str) -> ResolvedFont { // A vendor or family name may contain hyphens; start with the final face suffix. - let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or((ps, "")); + let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or_else(|| split_trailing_style(ps)); // Keep compound style suffixes the old heuristic understood, such as Bold-It. // Regular alone must not consume a family component named Bold or Black. if matches!(style.to_ascii_lowercase().as_str(), "it" | "italic" | "oblique") diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 47be8f38805..13de665ccd5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1991,3 +1991,20 @@ fn type_on_a_path_follows_the_curve() { dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; assert_eq!(ink(&mut eng, &dot), flat); } + +#[test] +fn hyphenless_postscript_names_split_trailing_styles() { + for (ps, family, weight, italic) in [ + ("GmarketSansBold", "Gmarket Sans", 700, false), + ("NanumGothicBold", "Nanum Gothic", 700, false), + ("GmarketSansTTFBold", "Gmarket Sans TTF", 700, false), + ("ExampleSansSemiBoldItalic", "Example Sans", 600, true), + ("ExampleSansExtraLight", "Example Sans", 200, false), + // A style word after a vendor tag belongs to a legacy family name; lowercase is no style. + ("ArialRoundedMTBold", "Arial Rounded MT Bold", 400, false), + ("Daylight", "Daylight", 400, false), + ] { + let r = fonts::guess_from_postscript(ps); + assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); + } +} diff --git a/crates/ui-egui/examples/unsharp_preview_bench.rs b/crates/ui-egui/examples/unsharp_preview_bench.rs new file mode 100644 index 00000000000..0f4518187a6 --- /dev/null +++ b/crates/ui-egui/examples/unsharp_preview_bench.rs @@ -0,0 +1,67 @@ +//! Synthetic existing preview workload, including proxy creation, command/history and CPU +//! flattening. Excludes scheduling, egui frames, display conversion, GPU upload/presentation. +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_ui_egui::{filter_dialog, proxy}; +use serde_json::json; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let n = |key: &str, default| std::env::var(key).ok().and_then(|s| s.parse::().ok()).unwrap_or(default); + let (w, h, reps) = (n("PHOTOCRAFT_UNSHARP_WIDTH", 6000), n("PHOTOCRAFT_UNSHARP_HEIGHT", 4000), n("PHOTOCRAFT_UNSHARP_REPS", 5)); + if w == 0 || h == 0 || w > 8192 || h > 8192 || !(1..=15).contains(&reps) { + return Err("invalid benchmark dimensions/reps".into()); + } + let depth = match n("PHOTOCRAFT_UNSHARP_DEPTH", 8) { + 8 => SampleType::U8, + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => return Err("invalid depth".into()), + }; + let mut doc = Document::with_background("Synthetic Unsharp Mask", Size::new(w, h), ColorMode::Rgb, depth, Color::WHITE); + let layer = doc.layers.first_mut().ok_or("missing layer")?; + let id = layer.id; + let LayerContent::Raster(s) = &mut layer.content else { return Err("raster required".into()) }; + for y in 0..h as i32 { + let data: Vec<_> = (0..w as i32) + .flat_map(|x| [((x * 7 + y * 13) % 257) as f32 / 256.0, ((x ^ y) % 251) as f32 / 250.0, ((x + y * 11) % 127) as f32 / 126.0, 1.0]) + .collect(); + s.write_region(Rect::new(0, y, w as i32, y + 1), &data); + } + let cmd = "filter.sharpen.unsharpMask"; + 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 v: Vec = case.split(':').map(str::parse).collect::>()?; + let (&radius, &amount, &threshold) = (v.first().ok_or("radius")?, v.get(1).ok_or("amount")?, v.get(2).ok_or("threshold")?); + for reduced in [true, false] { + let k = filter_dialog::preview_factor(cmd, &json!({"radius":radius}), proxy::preview_factor(&doc, reduced)); + let mut times = Vec::new(); + let mut checksum = 0u64; + for rep in 0..reps + 2 { + let start = Instant::now(); + let result = filter_dialog::preview_document(&doc, Some(id), cmd, &json!({"radius":radius,"amount":amount,"threshold":threshold}), k) + .ok_or("preview failed")?; + let pixels = photocraft_compose::flatten(&result); + let ms = start.elapsed().as_secs_f64() * 1000.0; + // Outside timing: stable hash over the full flattened result, no image file needed. + let hash = pixels.px.iter().flatten().fold(0u64, |h, v| h.wrapping_mul(1099511628211).wrapping_add(u64::from(v.to_bits()))); + if rep == 0 { + checksum = hash; + } else if checksum != hash { + return Err("unstable preview output".into()); + } + if rep >= 2 { + times.push(ms); + } + } + let mut sorted = times.clone(); + sorted.sort_by(f64::total_cmp); + println!( + "{}", + json!({"width":w,"height":h,"depth":n("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"proxy_factor":k,"runs_ms":times,"median_ms":sorted.get(sorted.len()/2),"checksum":checksum,"workers":rayon::current_num_threads(),"scope":"existing preview_document + CPU flatten; excludes GUI and GPU"}) + ); + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs new file mode 100644 index 00000000000..e8196bc1c2f --- /dev/null +++ b/crates/ui-egui/src/background_redraw.rs @@ -0,0 +1,252 @@ +//! Full CPU redraws of the GPU canvas off the UI thread (#4024). When the wgpu compositor can't +//! draw a full refresh (the layers exceed its memory budget, or the document uses something it +//! doesn't cover), the CPU compositor redraws the whole document: minutes on a multi-gigabyte +//! PSB. With background jobs (the desktop app) a worker composites it in blocks instead, the +//! visible ones first, and each frame uploads the blocks that are done, so the window keeps +//! drawing and answering while the rest of the canvas fills in. Until a block lands the texture +//! keeps what it showed before. +//! +//! An edit while it runs restarts the worker on the new document with the blocks still to draw; +//! the edit's own damage is drawn by the canvas as usual. A full change (a new preview, a new +//! display transform) starts over. Without background jobs (web, tests) the canvas keeps the +//! synchronous [`GpuCanvas::upload_composite`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender, TryRecvError}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::display_color::CanvasDisplay; +use photocraft_geom::Rect; +#[cfg(not(target_arch = "wasm32"))] +use rayon::prelude::*; + +use crate::PhotocraftApp; +use crate::gpu_canvas::GpuCanvas; + +/// Side of a block in document pixels: whole render tiles, and a power of two so each block's +/// mip levels don't depend on its neighbours. +const BLOCK: i32 = 1024; + +/// Blocks composited at once (in parallel, on top of each block's own tiles), and how many +/// finished blocks may wait for the UI thread: bounds the memory a redraw holds. +const BATCH: usize = 4; + +/// One finished block, as texels of the canvas texture. +struct Piece { + block: usize, + texels: Vec, +} + +/// A redraw in progress for one document's GPU texture. +pub(crate) struct Redraw { + blocks: Vec, + pending: Vec, + left: usize, + /// Pixels uploaded so far. + drawn: u64, + rx: Receiver, + stop: Arc, + started_ms: f64, + fallback: Option, +} + +impl Redraw { + /// Share of the document drawn, 0..=1. + pub fn fraction(&self) -> f32 { + if self.blocks.is_empty() { 1.0 } else { 1.0 - self.left as f32 / self.blocks.len() as f32 } + } +} + +impl Drop for Redraw { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +/// Whether full CPU refreshes run in the background here. +pub(crate) fn enabled(app: &PhotocraftApp) -> bool { + cfg!(not(target_arch = "wasm32")) && app.background_jobs +} + +/// `bounds` cut into [`BLOCK`]-sized blocks, row by row. +fn blocks(bounds: Rect) -> Vec { + let mut out = Vec::new(); + let mut y = bounds.y0; + while y < bounds.y1 { + let mut x = bounds.x0; + while x < bounds.x1 { + out.push(Rect::new(x, y, (x + BLOCK).min(bounds.x1), (y + BLOCK).min(bounds.y1))); + x += BLOCK; + } + y += BLOCK; + } + out +} + +/// The order to draw `wanted` blocks in: those `visible` shows first, then the rest, each group +/// nearest the view's centre first. +fn order(blocks: &[Rect], wanted: impl Iterator, visible: Rect) -> Vec { + let (cx, cy) = ((visible.x0 as i64 + visible.x1 as i64) / 2, (visible.y0 as i64 + visible.y1 as i64) / 2); + let mut out: Vec = wanted.collect(); + out.sort_by_key(|&i| { + let b = blocks[i]; + let (bx, by) = ((b.x0 as i64 + b.x1 as i64) / 2, (b.y0 as i64 + b.y1 as i64) / 2); + (b.intersect(&visible).is_empty(), (bx - cx).pow(2) + (by - cy).pow(2)) + }); + out +} + +/// Redraw `region` of document `doc` (`doc.id`'s texture, prepared by +/// [`GpuCanvas::refresh_with`]) in the background. A redraw already running for it continues on +/// `doc` with `region`'s blocks added. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start( + app: &mut PhotocraftApp, + ctx: &egui::Context, + gpu: &GpuCanvas, + doc: Arc, + display: Option>, + visible: Rect, + region: Rect, + fallback: Option, +) { + let all = blocks(doc.bounds()); + let (mut pending, since, fallback) = match app.redraws.remove(&doc.id) { + Some(old) if old.blocks == all => (old.pending.clone(), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.clone())), + _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback), + }; + for (p, b) in pending.iter_mut().zip(&all) { + *p |= !b.intersect(®ion).is_empty(); + } + if pending.contains(&true) { + spawn(app, ctx, gpu, doc, display, visible, all, pending, since, fallback); + } +} + +/// After an edit drew its own damage: continue document `doc.id`'s redraw, if one is running, on +/// the edited document, so blocks still to come don't bring back what was there before it. +pub(crate) fn restart(app: &mut PhotocraftApp, ctx: &egui::Context, gpu: &GpuCanvas, doc: Arc, display: Option>, visible: Rect) { + if app.redraws.contains_key(&doc.id) { + start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None); + } +} + +/// Upload the blocks document `doc`'s redraw has finished since the last frame. When the last one +/// lands, the redraw is recorded like the synchronous refresh it replaces (and explained once if +/// it was slow). +pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { + let Some(r) = app.redraws.get_mut(&doc) else { return }; + loop { + match r.rx.try_recv() { + Ok(p) => { + let b = r.blocks[p.block]; + if r.pending[p.block] && gpu.upload_rect(doc.0, [b.x0 as u32, b.y0 as u32], [b.width(), b.height()], &p.texels) { + r.pending[p.block] = false; + r.left -= 1; + r.drawn += b.width() as u64 * b.height() as u64; + } + } + Err(TryRecvError::Empty) => return, + Err(TryRecvError::Disconnected) => break, + } + } + let Some(r) = app.redraws.remove(&doc) else { return }; + if r.left > 0 { + log::warn!("canvas redraw stopped with {} of {} blocks left", r.left, r.blocks.len()); + return; + } + let ms = crate::gpu_canvas::now_ms() - r.started_ms; + app.perf.record("background", r.drawn, ms, 0.0); + crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_deref()); +} + +/// The active document's redraw in the status bar: "Redrawing" and how much is done. +pub(crate) fn status(app: &PhotocraftApp, ui: &mut egui::Ui) { + let Some(r) = app.session.active().and_then(|st| app.redraws.get(&st.doc.id)) else { return }; + let t = crate::theme::Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(format!("{:.0}%", r.fraction() * 100.0)).color(t.text_dim).size(11.5).monospace()); + ui.label(egui::RichText::new(tl!("Redrawing")).color(t.text_dim).size(12.0)); +} + +/// Stop document `doc`'s redraw (its canvas was redrawn some other way, or is gone). +pub(crate) fn cancel(app: &mut PhotocraftApp, doc: DocId) { + app.redraws.remove(&doc); +} + +/// Stop redraws of documents that are no longer open. +pub(crate) fn retain(app: &mut PhotocraftApp) { + let docs = app.session.documents(); + app.redraws.retain(|id, _| docs.iter().any(|st| st.doc.id == *id)); +} + +#[allow(clippy::too_many_arguments)] +fn spawn( + app: &mut PhotocraftApp, + ctx: &egui::Context, + gpu: &GpuCanvas, + doc: Arc, + display: Option>, + visible: Rect, + blocks: Vec, + pending: Vec, + (started_ms, drawn): (f64, u64), + fallback: Option, +) { + let id = doc.id; + let Some(format) = gpu.prepare_texture(id.0, &doc) else { return }; + let todo = order(&blocks, (0..blocks.len()).filter(|&i| pending[i]), visible); + let left = todo.len(); + let (tx, rx) = std::sync::mpsc::sync_channel(BATCH); + let stop = Arc::new(AtomicBool::new(false)); + let work = (blocks.clone(), stop.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("canvas-redraw".into()).spawn(move || { + let (blocks, stop, ctx) = work; + // A panic stops the redraw: the receiver sees the worker gone and drops it. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| draw(&doc, display.as_deref(), format, &blocks, &todo, &stop, &tx, &ctx))); + ctx.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("couldn't start the canvas redraw: {e}"); + return; + } + app.redraws.insert(id, Redraw { blocks, pending, left, drawn, rx, stop, started_ms, fallback }); +} + +/// The worker: composite `todo`'s blocks of `doc` in batches and hand each over, until done or +/// stopped. +#[allow(clippy::too_many_arguments)] +fn draw( + doc: &Document, + display: Option<&CanvasDisplay>, + format: eframe::wgpu::TextureFormat, + blocks: &[Rect], + todo: &[usize], + stop: &AtomicBool, + tx: &SyncSender, + ctx: &egui::Context, +) { + for batch in todo.chunks(BATCH) { + if stop.load(Ordering::Relaxed) { + return; + } + // Never reached in the browser ([`enabled`]); it only has to build there, without rayon. + #[cfg(not(target_arch = "wasm32"))] + let batch = batch.par_iter(); + #[cfg(target_arch = "wasm32")] + let batch = batch.iter(); + let pieces: Vec = batch + .map(|&block| Piece { block, texels: crate::gpu_canvas::display_texels(format, display, &photocraft_compose::render(doc, blocks[block])) }) + .collect(); + for p in pieces { + if stop.load(Ordering::Relaxed) || tx.send(p).is_err() { + return; + } + ctx.request_repaint(); + } + } +} + +#[cfg(test)] +#[path = "background_redraw_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs new file mode 100644 index 00000000000..e054ffe2a65 --- /dev/null +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -0,0 +1,165 @@ +//! Background full CPU redraws (#4024). The GPU tests skip when there is no adapter. + +use super::*; +use serde_json::json; + +#[test] +fn blocks_cover_the_document_once() { + let bounds = Rect::new(0, 0, 2500, 1100); + let b = blocks(bounds); + assert_eq!(b.len(), 3 * 2); + assert_eq!(b.iter().map(|r| r.width() as u64 * r.height() as u64).sum::(), 2500 * 1100); + for (i, r) in b.iter().enumerate() { + assert!(r.x0 % BLOCK == 0 && r.y0 % BLOCK == 0, "block {i} starts on the grid: {r:?}"); + assert!(b[i + 1..].iter().all(|o| o.intersect(r).is_empty()), "block {i} overlaps another"); + } +} + +#[test] +fn visible_blocks_come_first_nearest_the_view_centre() { + let b = blocks(Rect::new(0, 0, 4096, 4096)); + // The view shows the right half of the second block row. + let visible = Rect::new(2100, 1100, 4000, 1900); + let o = order(&b, 0..b.len(), visible); + assert_eq!(o.len(), 16); + let shown: Vec = (0..b.len()).filter(|&i| !b[i].intersect(&visible).is_empty()).collect(); + assert_eq!(shown.len(), 2); + assert!(o[..2].iter().all(|i| shown.contains(i)), "{o:?}"); + // Then the block row's neighbours, the far corners last. + assert!(o[14..].contains(&12), "the bottom-left corner is among the last: {o:?}"); + // Only the blocks asked for. + assert_eq!(order(&b, [3, 9].into_iter(), visible).len(), 2); +} + +struct Gpu { + harness: egui_kittest::Harness<'static, PhotocraftApp>, + observer: GpuCanvas, +} + +/// The app on a real device with background jobs, or `None` without a GPU adapter. +fn gpu_app() -> Option { + let observer = Arc::new(std::sync::Mutex::new(None)); + let observer_out = observer.clone(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + *observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs)); + app.set_wgpu(rs.clone()); + } + app.background_jobs = true; + app + }) + })); + let Ok(harness) = built else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + let observer = observer.lock().expect("observer").take()?; + Some(Gpu { harness, observer }) +} + +/// A document the wgpu compositor leaves to the CPU (knockout on its top layer, which also has +/// Blend If). +fn cpu_only_document(app: &mut PhotocraftApp, w: u32, h: u32) -> DocId { + app.run("file.new", json!({"width": w, "height": h, "background": "#336699"})).expect("new"); + app.run("layer.new.layer", json!({})).expect("layer"); + app.run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + app.run("layer.layerStyle.blendingOptions", json!({"opacity": 60, "knockout": "shallow", "blendIf": {"channel": "gray", "underlying": [0, 10, 245, 255]}})) + .expect("blending options"); + app.session.active().expect("document").doc.id +} + +/// Frames until no redraw is running. +fn settle(g: &mut Gpu) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + g.harness.step(); + if g.harness.state().redraws.is_empty() { + return; + } + assert!(std::time::Instant::now() < deadline, "the redraw didn't finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// The canvas texture of `doc` against the synchronous CPU refresh of the current document. +fn assert_matches_synchronous(g: &mut Gpu, doc: DocId) { + let app = g.harness.state_mut(); + let d = app.session.active().expect("document").doc.clone(); + let (display, _) = crate::canvas::canvas_display(app, &d, None); + let reference = doc.0 ^ (1u64 << 60); + g.observer.upload_composite(reference, &d, display.as_deref()); + let (_, size, shown) = g.observer.read_texels(doc.0).expect("canvas texture"); + let (_, _, want) = g.observer.read_texels(reference).expect("reference texture"); + assert_eq!(size, [d.size.width, d.size.height]); + let differ = shown.iter().zip(&want).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "{differ} texels differ from the synchronous refresh"); +} + +#[test] +fn a_full_cpu_refresh_draws_in_the_background_with_the_synchronous_pixels() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "the frame started a redraw instead of compositing inline"); + settle(&mut g); + let app = g.harness.state(); + assert_eq!(app.perf.last_refresh, "background"); + assert!(app.perf.gpu_fallback.as_deref().is_some_and(|f| f.contains("Advanced Blending")), "{:?}", app.perf.gpu_fallback); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + // 4×3 blocks: the worker can't hand them all over before the next frame. + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc)); + // A corner, drawn last (farthest from the view's centre), changes on a layer of its own while + // the redraw runs: a small edit, drawn at once. + g.harness.state_mut().run("layer.new.layer", json!({})).expect("layer"); + g.harness.step(); + g.harness.state_mut().run("select.rect", json!({"x": 3900, "y": 2900, "width": 150, "height": 150})).expect("select"); + g.harness.step(); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + g.harness.step(); + assert_eq!(g.harness.state().perf.last_refresh, "rect", "the fill's damage was drawn synchronously"); + assert!(g.harness.state().redraws.contains_key(&doc), "the redraw goes on"); + settle(&mut g); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn an_edit_damaging_most_of_a_large_document_is_drawn_in_the_background_too() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); + settle(&mut g); + // Filling part of the Blend If layer can change the composite anywhere: the whole canvas. + g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 300, "height": 300})).expect("select"); + g.harness.step(); + g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); + assert_eq!(g.harness.state().session.active().expect("document").last_damage, Some(Rect::new(0, 0, 2600, 1700))); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc), "not composited inline: {}", g.harness.state().perf.last_refresh); + settle(&mut g); + assert_eq!(g.harness.state().perf.last_refresh, "background"); + assert_matches_synchronous(&mut g, doc); +} + +#[test] +fn closing_the_document_stops_its_redraw() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072); + g.harness.step(); + assert!(g.harness.state().redraws.contains_key(&doc)); + g.harness.state_mut().run("file.close", json!({"save": false})).expect("close"); + g.harness.step(); + assert!(g.harness.state().redraws.is_empty()); +} diff --git a/crates/ui-egui/src/blur_pins.rs b/crates/ui-egui/src/blur_pins.rs new file mode 100644 index 00000000000..6a90d9910ae --- /dev/null +++ b/crates/ui-egui/src/blur_pins.rs @@ -0,0 +1,311 @@ +//! Filter › Blur Gallery › Field Blur… as Photoshop's workspace (#3373): pins on the image, each +//! with its own blur, interpolated between them. While the dialog is open the image under it takes +//! the pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on +//! the selected pin's ring turns its blur up or down (one turn = the full 0–500 px; the arc shows +//! the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's +//! Blur and the Preview switch; OK runs `filter.blurGallery.fieldBlur` with the `pins`. +//! +//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins` +//! (`[{x, y, blur}]`, x and y as fractions of the filter's bounds) and `__selected`. The bounds are +//! the engine's: the selection's when there is one, else the canvas. + +use egui::{Color32, Pos2, Stroke, vec2}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +pub const COMMAND: &str = "filter.blurGallery.fieldBlur"; +/// Field Blur's range, in pixels. +const MAX_BLUR: f64 = 500.0; +/// Photoshop's first pin. +const DEFAULT_BLUR: f64 = 15.0; +/// More pins than anyone places by hand; keeps the params and the per-pixel loop bounded. +pub const MAX_PINS: usize = 64; +/// Screen radii (points): the pin's dot, how near a press must be to grab it, and its ring. +const DOT: f32 = 5.0; +const GRAB: f32 = 9.0; +const RING: f32 = 24.0; +const RING_BAND: f32 = 5.0; + +pub fn owns(fields: &Map) -> bool { + fields.get("__command").and_then(Value::as_str) == Some(COMMAND) +} + +/// Turn a freshly opened Field Blur dialog into the workspace: one pin in the middle of the +/// filter's bounds with the remembered blur, selected. +pub fn init(app: &mut PhotocraftApp, fields: &mut Map) { + reset(fields); + let bounds = app.session.active().map(|st| (st.doc.id, st.doc.bounds(), st.doc.selection.clone())); + if let Some((doc, canvas, selection)) = bounds { + let b = selection.map(|s| app.cached_bounds(u64::MAX - doc.0, &s)).filter(|b| !b.is_empty()).unwrap_or(canvas); + fields.insert("__bounds".into(), json!([b.x0, b.y0, b.x1, b.y1])); + } +} + +/// One pin in the middle with the dialog's blur, selected. Alt+Cancel (Reset) runs this alone, so +/// the bounds captured on opening stay. +pub fn reset(fields: &mut Map) { + let blur = fields.get("blur").and_then(Value::as_f64).filter(|b| b.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + fields.remove("centerX"); + fields.remove("centerY"); + fields.insert("blur".into(), json!(blur)); + fields.insert("pins".into(), json!([{"x": 0.5, "y": 0.5, "blur": blur}])); + fields.insert("__selected".into(), json!(0)); +} + +/// Where the dialog first opens (offset from the window's top-left): at the right edge, beside the +/// image, as Photoshop docks its Blur Tools panel, so the pins under it stay in view. Moved, it +/// reopens where it was left, like any dialog. +pub fn first_place(fields: &Map, ctx: &egui::Context) -> Option { + owns(fields).then(|| vec2((ctx.content_rect().width() - 480.0).max(0.0), 96.0)) +} + +/// One pin: position as fractions of the bounds, blur in pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pin { + pub x: f64, + pub y: f64, + pub blur: f64, +} + +/// The pins in `fields`, cleaned (finite, in range, at most [`MAX_PINS`]). +pub fn pins(fields: &Map) -> Vec { + let Some(list) = fields.get("pins").and_then(Value::as_array) else { return Vec::new() }; + let num = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d); + list.iter() + .take(MAX_PINS) + .map(|v| Pin { x: num(v, "x", 0.5).clamp(0.0, 1.0), y: num(v, "y", 0.5).clamp(0.0, 1.0), blur: num(v, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR) }) + .collect() +} + +fn set_pins(fields: &mut Map, pins: &[Pin]) { + let list: Vec = pins.iter().map(|p| json!({"x": p.x, "y": p.y, "blur": p.blur})).collect(); + fields.insert("pins".into(), Value::Array(list)); +} + +/// The selected pin's index, if it exists. +pub fn selected(fields: &Map) -> Option { + let n = pins(fields).len(); + fields.get("__selected").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < n) +} + +fn select(fields: &mut Map, i: Option) { + fields.insert("__selected".into(), i.map_or(Value::Null, |i| json!(i))); + // The dialog's Blur (remembered for the next Field Blur) follows the selected pin. + if let Some(p) = i.and_then(|i| pins(fields).get(i).copied()) { + fields.insert("blur".into(), json!(p.blur)); + } +} + +/// The filter's bounds in document pixels `[x0, y0, x1, y1]`. +fn bounds(fields: &Map) -> Option<[f64; 4]> { + let b = fields.get("__bounds")?.as_array()?; + let v = |i: usize| b.get(i).and_then(Value::as_f64); + let r = [v(0)?, v(1)?, v(2)?, v(3)?]; + (r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +fn to_doc(b: [f64; 4], p: Pin) -> [f64; 2] { + [b[0] + p.x * (b[2] - b[0]), b[1] + p.y * (b[3] - b[1])] +} + +fn from_doc(b: [f64; 4], d: [f64; 2]) -> (f64, f64) { + (((d[0] - b[0]) / (b[2] - b[0])).clamp(0.0, 1.0), ((d[1] - b[1]) / (b[3] - b[1])).clamp(0.0, 1.0)) +} + +/// Remove the selected pin; the one before it (or the new first) is selected. +pub fn remove_selected(fields: &mut Map) -> bool { + let Some(i) = selected(fields) else { return false }; + let mut list = pins(fields); + list.remove(i); + set_pins(fields, &list); + select(fields, if list.is_empty() { None } else { Some(i.saturating_sub(1)) }); + true +} + +/// The dialog body: the selected pin's Blur, how to place pins, and Preview. +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let t = crate::theme::Tokens::get(ui.ctx()); + match selected(f) { + Some(i) => { + let mut list = pins(f); + if let Some(pin) = list.get_mut(i) { + let mut v = pin.blur as f32; + if crate::widgets::slider_row(ui, tl!("Blur"), &mut v, 0.0..=MAX_BLUR as f32, "px", None).changed() { + pin.blur = f64::from(v.round()).clamp(0.0, MAX_BLUR); + set_pins(f, &list); + select(f, Some(i)); + } + } + } + None => { + ui.label(egui::RichText::new(tl!("No pin selected")).color(t.text_dim)); + } + } + ui.add_space(4.0); + ui.add( + egui::Label::new( + egui::RichText::new(tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin.")) + .color(t.text_dim) + .size(11.5), + ) + .wrap(), + ); + // Delete removes the selected pin, unless a field is being typed in. + let typing = ui.ctx().memory(|m| m.focused().is_some()); + if !typing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) { + remove_selected(f); + ui.ctx().request_repaint(); + } + if let Some(mut preview) = f.get("__preview").and_then(Value::as_bool) { + ui.add_space(4.0); + crate::widgets::checkbox(ui, &mut preview, tl!("Preview")); + f.insert("__preview".into(), json!(preview)); + } +} + +/// What a press at screen point `at` grabs: the selected pin's ring, a pin, or nothing (a new pin). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grab { + Ring(usize), + Pin(usize), + New, +} + +fn grab(fields: &Map, xf: &ViewXform, at: Pos2) -> Grab { + let Some(b) = bounds(fields) else { return Grab::New }; + let list = pins(fields); + let screen = |p: Pin| { + let d = to_doc(b, p); + xf.to_screen(d[0] as f32, d[1] as f32) + }; + if let Some(i) = selected(fields) + && let Some(p) = list.get(i) + && (screen(*p).distance(at) - RING).abs() <= RING_BAND + { + return Grab::Ring(i); + } + list.iter() + .enumerate() + .map(|(i, p)| (i, screen(*p).distance(at))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(Grab::New, |(i, _)| Grab::Pin(i)) +} + +/// The pointer on the free image under the dialog: `over` is where it is, `press` where the +/// primary button is held (`dialogs::free_press`), both in screen points. Returns whether the +/// workspace took it. +pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over: Pos2, press: Option) -> bool { + let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields)) else { return false }; + let f = &mut d.fields; + let Some(b) = bounds(f) else { return false }; + let (start, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + ctx.set_cursor_icon(match grab(f, xf, over) { + Grab::Ring(_) => egui::CursorIcon::Alias, + Grab::Pin(_) => egui::CursorIcon::Grab, + Grab::New => egui::CursorIcon::Crosshair, + }); + if !down && press.is_none() { + f.remove("__drag"); + return true; + } + let Some(at) = press else { return true }; + let doc = xf.to_doc(at); + let mut list = pins(f); + if start { + let drag = match grab(f, xf, at) { + Grab::Ring(i) => { + let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); + json!({"ring": i, "angle": (doc[1] - c[1]).atan2(doc[0] - c[0])}) + } + Grab::Pin(i) => { + select(f, Some(i)); + let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc); + json!({"pin": i, "dx": c[0] - doc[0], "dy": c[1] - doc[1]}) + } + Grab::New if list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => { + // A new pin takes the selected pin's blur, as the dialog's Blur shows it. + let blur = f.get("blur").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR); + let (x, y) = from_doc(b, doc); + list.push(Pin { x, y, blur }); + set_pins(f, &list); + select(f, Some(list.len() - 1)); + json!({"pin": list.len() - 1, "dx": 0.0, "dy": 0.0}) + } + Grab::New => return true, + }; + f.insert("__drag".into(), drag); + ctx.request_repaint(); + return true; + } + let Some(drag) = f.get("__drag").cloned() else { return true }; + if let Some(i) = drag.get("pin").and_then(Value::as_u64).map(|i| i as usize) + && let Some(p) = list.get_mut(i) + { + let num = |k: &str| drag.get(k).and_then(Value::as_f64).unwrap_or(0.0); + (p.x, p.y) = from_doc(b, [doc[0] + num("dx"), doc[1] + num("dy")]); + set_pins(f, &list); + } else if let Some(i) = drag.get("ring").and_then(Value::as_u64).map(|i| i as usize) + && let Some(p) = list.get_mut(i) + { + // The angle turned since the last frame, the short way round: clockwise raises the blur. + let c = to_doc(b, *p); + let a = (doc[1] - c[1]).atan2(doc[0] - c[0]); + let last = drag.get("angle").and_then(Value::as_f64).unwrap_or(a); + let turn = (a - last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + p.blur = (p.blur + turn / std::f64::consts::TAU * MAX_BLUR).clamp(0.0, MAX_BLUR); + set_pins(f, &list); + select(f, Some(i)); + f.insert("__drag".into(), json!({"ring": i, "angle": a})); + } + ctx.request_repaint(); + true +} + +/// Draw the pins over the image while the workspace is open: a dot per pin (filled when +/// selected) and, round the selected one, its ring with the blur as an arc from the top. +pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return }; + let Some(b) = bounds(&d.fields) else { return }; + let sel = selected(&d.fields); + let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235))); + for (i, p) in pins(&d.fields).into_iter().enumerate() { + let c = to_doc(b, p); + let c = xf.to_screen(c[0] as f32, c[1] as f32); + if sel == Some(i) { + painter.circle_stroke(c, RING, dark); + painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140))); + let frac = (p.blur / MAX_BLUR) as f32; + let steps = (frac * 64.0).ceil().max(1.0) as usize; + let arc: Vec = (0..=steps) + .map(|k| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * k as f32 / steps as f32; + c + vec2(a.cos(), a.sin()) * RING + }) + .collect(); + if frac > 0.0 { + painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150)))); + painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE))); + } + painter.circle(c, DOT, Color32::WHITE, Stroke::new(1.0, Color32::from_black_alpha(180))); + } else { + painter.circle_stroke(c, DOT, dark); + painter.circle_stroke(c, DOT, light); + } + } +} + +/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them. +pub fn scale_for_proxy(params: &mut Value, k: u32) { + if k <= 1 { + return; + } + let Some(list) = params.get_mut("pins").and_then(Value::as_array_mut) else { return }; + for pin in list { + if let Some(b) = pin.get("blur").and_then(Value::as_f64) { + pin["blur"] = json!(b / f64::from(k)); + } + } +} diff --git a/crates/ui-egui/src/blur_pins_tests.rs b/crates/ui-egui/src/blur_pins_tests.rs new file mode 100644 index 00000000000..33a691d3d85 --- /dev/null +++ b/crates/ui-egui/src/blur_pins_tests.rs @@ -0,0 +1,193 @@ +//! #3373: Field Blur's pins are placed, dragged and tuned on the image under the dialog, the +//! preview follows them, and OK applies them. + +use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::blur_pins::{COMMAND, Pin, pins, selected}; +use crate::canvas::ViewXform; + +/// A 400×300 document at 400 %, so it fills the window round the dialog, with Field Blur open. +fn harness(selection: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if selection { + app.run("select.rect", json!({"x": 100, "y": 50, "width": 200, "height": 100})).unwrap(); + } + app.sync_views(); + app.ui.extras.rulers = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (4.0, [200.0, 150.0], false); + crate::filter_dialog::open(h.state_mut(), COMMAND).unwrap(); + h.run_steps(4); + h +} + +fn fields(h: &Harness<'static, PhotocraftApp>) -> serde_json::Map { + h.state().ui.dialogs.last().unwrap().fields.clone() +} + +fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: false, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + } +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); +} + +fn drag(h: &mut Harness<'static, PhotocraftApp>, path: &[Pos2]) { + move_to(h, path[0]); + button(h, path[0], true); + for p in &path[1..] { + move_to(h, *p); + } + button(h, *path.last().unwrap(), false); + h.run_steps(2); +} + +/// Where pin `p` is on screen. +fn on_screen(h: &Harness<'static, PhotocraftApp>, p: Pin, bounds: [f64; 4]) -> Pos2 { + let (x, y) = (bounds[0] + p.x * (bounds[2] - bounds[0]), bounds[1] + p.y * (bounds[3] - bounds[1])); + xf(h).to_screen(x as f32, y as f32) +} + +#[test] +fn it_opens_with_one_selected_pin_in_the_middle_of_the_bounds() { + let h = harness(false); + let f = fields(&h); + assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(selected(&f), Some(0)); + assert_eq!(f["__bounds"], json!([0, 0, 400, 300])); + assert!(!f.contains_key("centerX") && !f.contains_key("centerY"), "the pins replace the single centre"); + // With a selection the pins are fractions of its bounds, as the engine reads them. + let h = harness(true); + assert_eq!(fields(&h)["__bounds"], json!([100, 50, 300, 150])); +} + +#[test] +fn pins_are_added_moved_tuned_and_removed_on_the_image() { + let mut h = harness(false); + let r = h.state().last_canvas_rect; + // A click on the free image (left of the dialog) adds a pin there, selected. + let a = pos2(r.left() + 80.0, r.bottom() - 80.0); + move_to(&mut h, a); + button(&mut h, a, true); + button(&mut h, a, false); + h.run_steps(2); + let f = fields(&h); + let list = pins(&f); + assert_eq!(list.len(), 2, "{list:?}"); + assert_eq!(selected(&f), Some(1)); + let at = xf(&h).to_doc(a); + assert!((list[1].x - at[0] / 400.0).abs() < 1e-3 && (list[1].y - at[1] / 300.0).abs() < 1e-3, "{:?} vs {at:?}", list[1]); + assert_eq!(list[1].blur, 15.0, "a new pin takes the selected pin's blur"); + // Dragging the pin moves it by the pointer's movement (4 screen points per pixel at 400 %). + let b = a + vec2(40.0, -20.0); + drag(&mut h, &[a, a + vec2(20.0, -10.0), b]); + let moved = pins(&fields(&h))[1]; + assert!((moved.x - (list[1].x + 10.0 / 400.0)).abs() < 1e-3 && (moved.y - (list[1].y - 5.0 / 300.0)).abs() < 1e-3, "{moved:?}"); + assert!(fields(&h).get("__drag").is_none(), "the drag ends on release"); + // A quarter turn clockwise on its ring adds a quarter of the 0–500 px range. + let c = on_screen(&h, moved, [0.0, 0.0, 400.0, 300.0]); + let ring: Vec = (0..=6).map(|k| c + vec2(24.0 * (k as f32 * 0.2618).cos(), 24.0 * (k as f32 * 0.2618).sin())).collect(); + drag(&mut h, &ring); + let tuned = pins(&fields(&h))[1]; + assert!((tuned.blur - 140.0).abs() < 2.0, "15 + 125 px: {}", tuned.blur); + assert!((tuned.x - moved.x).abs() < 1e-9, "the ring doesn't move the pin"); + assert_eq!(fields(&h)["blur"], json!(tuned.blur), "the dialog's Blur follows the selected pin"); + // The preview runs the filter with the pins (computed by the canvas's GPU path in the app; + // asked for directly here, as the headless harness has no GPU). + assert!(crate::canvas::ensure_filter_preview(h.state_mut(), 0, &egui::Context::default()).is_some()); + let preview = h.state().filter_preview.as_ref().expect("a live preview"); + assert_eq!(preview.key.params["pins"].as_array().map(Vec::len), Some(2)); + // Delete removes the selected pin; OK applies the rest in one step. + h.key_press(egui::Key::Delete); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + assert_eq!(selected(&fields(&h)), Some(0)); + let id = h.state().ui.dialogs.last().unwrap().id; + let steps = h.state().session.active().unwrap().history.past_len(); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let (cmd, p) = h.state().session.journal.last().cloned().unwrap(); + assert_eq!(cmd, COMMAND); + assert_eq!(p["pins"], json!([{"x": 0.5, "y": 0.5, "blur": 15.0}]), "{p}"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); +} + +/// A click outside the bounds (beside a selection) adds no pin; pins are capped. +#[test] +fn pins_stay_inside_the_bounds_and_are_capped() { + let mut h = harness(true); + let r = h.state().last_canvas_rect; + let outside = pos2(r.left() + 30.0, r.top() + 30.0); + assert!(xf(&h).to_doc(outside)[0] < 100.0, "left of the selection"); + move_to(&mut h, outside); + button(&mut h, outside, true); + button(&mut h, outside, false); + h.run_steps(2); + assert_eq!(pins(&fields(&h)).len(), 1); + let many: Vec<_> = (0..200).map(|i| json!({"x": f64::from(i) / 200.0, "y": 0.5, "blur": 1e9})).collect(); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), json!(many)); + let list = pins(&fields(&h)); + assert_eq!(list.len(), crate::blur_pins::MAX_PINS); + assert!(list.iter().all(|p| p.blur == 500.0)); +} + +#[test] +fn proxy_previews_scale_the_pins_blur() { + let mut p = json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 40.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]}); + crate::blur_pins::scale_for_proxy(&mut p, 4); + assert_eq!(p, json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 10.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]})); + crate::blur_pins::scale_for_proxy(&mut p, 1); + assert_eq!(p["pins"][0]["blur"], json!(10.0)); +} + +/// Alt+Cancel → Reset (#3871) starts the workspace again from one pin in the middle, as when it +/// opened, instead of leaving it with no pins; the bounds captured on opening stay. +#[test] +fn alt_reset_starts_again_from_one_pin() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = crate::filter_dialog::open(&mut app, COMMAND).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let bounds = fields["__bounds"].clone(); + fields.insert("pins".into(), json!([{"x": 0.1, "y": 0.2, "blur": 40.0}, {"x": 0.8, "y": 0.7, "blur": 90.0}])); + fields.insert("__selected".into(), json!(1)); + assert!(crate::dialog_alt_reset::supported(&fields)); + crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields); + assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0 }]); + assert_eq!(selected(&fields), Some(0)); + assert_eq!(fields["__bounds"], bounds); + assert!(!fields.contains_key("centerX") && !fields.contains_key("centerY"), "{fields:?}"); +} diff --git a/crates/ui-egui/src/brush_preview.rs b/crates/ui-egui/src/brush_preview.rs index a3d2d83121d..ec6b36c07b4 100644 --- a/crates/ui-egui/src/brush_preview.rs +++ b/crates/ui-egui/src/brush_preview.rs @@ -353,7 +353,7 @@ mod tests { let s = tip_alpha(&TipShape::Sampled(bar), 1.0, 0.0, 1.0, (false, false), n); assert!(at(&s, 3, 16) > 0.9 && at(&s, 16, 2) < 0.01); // Degenerate input never panics. - let empty = paint::GrayTile { width: 0, height: 0, data: vec![] }; + let empty = paint::GrayTile { width: 0, height: 0, data: vec![].into() }; assert!(tip_alpha(&TipShape::Sampled(empty), 1.0, 0.0, 0.0, (true, true), 0).iter().all(|v| *v == 0.0)); } } diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 59ab86092d0..f3e2aa8fbbc 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -1435,6 +1435,9 @@ mod tests { let ctx = egui::Context::default(); let warnings = app.open_bytes("IMG_0001.dng", &dng).unwrap(); assert!(warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)), "{warnings:?}"); + // A raw has no embedded profile (ProPhoto RGB is the space it is developed into), so Ask + // When Opening (on by default) has nothing to ask about. + assert!(app.ui.dialogs.is_empty(), "no profile prompt for a raw"); let raw = app.pending_raw_open.take().expect("a raw open queues the dialog"); open_raw(&mut app, &ctx, raw).unwrap(); let d = app.camera_raw.as_ref().unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d7405b4489d..df8b4d82605 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,11 +50,15 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie (h.finish(), radius.to_bits(), at.map(f64::to_bits)) }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}", - st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend + "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}", + st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview ); if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &map, &app.ui.tool_options.clone_sample) else { return }; + let Ok(mut buf) = + photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview) + else { + return; + }; if overlay.invert { for px in &mut buf.px { for c in px.iter_mut().take(3) { @@ -66,7 +70,9 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie // composite, whatever Sample reads), so Difference turns black where they line up. if overlay.blend != "normal" { let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] }; - let Ok(under) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &here, "all") else { return }; + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + return; + }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } @@ -1011,18 +1017,51 @@ fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { } fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { - let img = buf.to_rgba8(); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) + // `buf.to_rgba8()` then `color_image`, fused: one parallel pass, no intermediate image. + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + let mut pixels = vec![egui::Color32::TRANSPARENT; buf.px.len()]; + let convert = |out: &mut [egui::Color32], src: &[[f32; 4]]| { + for (o, p) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(q(p[0]), q(p[1]), q(p[2]), q(p[3])); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(buf.px.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, &buf.px); + egui::ColorImage::new([buf.rect.width() as usize, buf.rect.height() as usize], pixels) +} + +/// `img` as an egui image: `ColorImage::from_rgba_unmultiplied`, in parallel off wasm. egui's own +/// conversion runs on one thread and took 37 ms of a 24 MP canvas or preview update, three times +/// the quantize before it. +fn color_image(img: &photocraft_raster::Rgba8Image) -> egui::ColorImage { + let size = [img.width as usize, img.height as usize]; + let src = img.pixels.as_chunks::<4>().0; + let mut pixels = vec![egui::Color32::TRANSPARENT; src.len()]; + let convert = |out: &mut [egui::Color32], src: &[[u8; 4]]| { + for (o, &[r, g, b, a]) in out.iter_mut().zip(src) { + *o = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + pixels.par_chunks_mut(16 * 1024).zip(src.par_chunks(16 * 1024)).for_each(|(o, s)| convert(o, s)); + } + #[cfg(target_arch = "wasm32")] + convert(&mut pixels, src); + egui::ColorImage::new(size, pixels) } /// A composite as monitor pixels: document profile → monitor profile (nothing to do in the /// common sRGB-on-sRGB case). pub(crate) fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage { match display { - Some(d) if !d.is_identity() => { - let img = d.to_rgba8(buf); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) - } + Some(d) if !d.is_identity() => color_image(&d.to_rgba8(buf)), _ => buffer_to_image(buf), } } @@ -1509,7 +1548,7 @@ pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 { /// GPU path: make sure document `idx` is current in the GPU canvas. Edits draw their damage; /// unchanged lower-stack composites can be reused by the compositor inside that region. /// Returns false if there is no GPU canvas. -fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { +fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: DRect) -> bool { let Some(gpu) = app.gpu.clone() else { return false }; let Some((revision, last_damage, id)) = app .session @@ -1541,6 +1580,8 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { app.navigator_gpu.remove(&id); } let size = [doc.size.width, doc.size.height]; + // A full CPU redraw running in the background: show the blocks it has finished. + crate::background_redraw::poll(app, &gpu, id); let cache = app.canvases.entry((id, GPU_OUTPUT)).or_insert(CanvasCache { revision: 0, texture: None, @@ -1563,22 +1604,37 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { // Display textures for a canvas this large would not fit the GPU: the reduced CPU canvas // draws it (checked before the memory budget, which walks the history, every frame). if !gpu.fits_canvas(&doc) { + crate::background_redraw::cancel(app, id); return false; } let partial = present && damage.is_some(); gpu_budget(app, &gpu, idx, partial, visible); - let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref()); + let defer = crate::background_redraw::enabled(app); + let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer); if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET { // The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the // texture budget: draw this frame on the CPU path. + crate::background_redraw::cancel(app, id); return false; } + if r.kind == crate::gpu_canvas::REFRESH_DEFERRED { + // Composited off the UI thread, the visible part first (#4024). + let region = if partial { damage.unwrap_or(doc.bounds()) } else { doc.bounds() }; + crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone()); + } else if partial { + // The edit drew its damage; blocks of a running redraw still to come follow the edit. + crate::background_redraw::restart(app, ctx, &gpu, doc.clone(), display.clone(), visible); + } else { + crate::background_redraw::cancel(app, id); + } if let Some(e) = &r.fallback && app.perf.gpu_fallback.as_deref() != Some(e.as_str()) { log::info!("{e}; using the CPU compositor"); } - app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + if r.kind != crate::gpu_canvas::REFRESH_DEFERRED { + app.perf.record(r.kind, r.px, r.composite_ms, r.upload_ms); + } if r.kind == "full" { crate::notices::slow_cpu_refresh(app, id.0, r.composite_ms + r.upload_ms, r.fallback.as_deref()); } @@ -1906,6 +1962,7 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { live.extend(crate::adjust_preview::gpu_keys(app)); gpu.retain(&live); } + crate::background_redraw::retain(app); // Upload markers must not outlive the resources they describe: native reopen can reuse // both the document ID and revision before the next frame while a preview dialog stays open. let documents = app.session.documents(); @@ -2895,7 +2952,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: offset: [0.0, 0.0], }; paint_gpu_view(&painter, rect, params, &pattern); - } else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) { + } else if gpu_ok && ensure_gpu(app, &ctx, idx, visible_doc_rect(&xf)) { on_gpu = true; app.perf.gpu = true; // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). @@ -3178,7 +3235,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Select and Mask's Refine Edge Brush paints on the image. let refine_brushing = primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing; + // Field Blur's pins are placed and dragged on the image (`blur_pins`). + let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3202,6 +3261,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::color_picker_ui::sample_at(app, d[0], d[1]); } } + } else if pinning + && !middle + && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) + { + let press = crate::dialogs::free_press(&ctx, rect); + crate::blur_pins::canvas(app, &ctx, &xf, p, press); } else if refine_brushing && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) @@ -4409,6 +4474,7 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, crate::tool_feedback::draw_ants(painter, &pts, false); } crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover); + crate::blur_pins::draw(app, painter, xf); if let Some(c) = app.ui.crop_rect { let deg = crate::crop_ui::angle(app); let shield = crate::crop_shield::current(app, painter.ctx()); @@ -5742,6 +5808,33 @@ mod tabs_tests; #[cfg(test)] mod tests { + #[test] + fn display_image_matches_egui_unmultiplied_conversion() { + // Several parallel pieces and a ragged one; translucent, out-of-range 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 % 6) as f32 / 5.0] + }) + .collect(); + let buf = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(0, 0, n as i32, 1), px }; + let reference = |img: photocraft_raster::Rgba8Image| egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); + assert_eq!(super::display_image(None, &buf).pixels, reference(buf.to_rgba8()).pixels); + let mut doc = photocraft_doc::Document::new("t", photocraft_geom::Size::new(1, 1), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + doc.icc_profile = Some(photocraft_cms::Builtin::AdobeRgbCompat.profile().to_bytes()); + let mut color = photocraft_engine::color_cmds::ColorState::default(); + color.settings.monitor_profile = "srgb".into(); + let d = color.canvas_display(&doc).unwrap(); + assert!(!d.is_identity()); + assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { @@ -6383,6 +6476,16 @@ mod tests { assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.presets.clone.active().anchor.is_none()); + let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); + app.ui.view.pattern_preview = true; + let mut periodic_output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); + }); + assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); + assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit"); + periodic_output.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().revision, revision); app.session.presets.clone.overlay.show = false; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index 38116e58e19..b8be54d2736 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -23,6 +23,8 @@ const MAX_COMPOSITE_CACHE_PIXELS: usize = 8 * 1024 * 1024; struct SampledComposite { revision: u64, + /// `revision` minus the view-only part: what the pixels depend on. + content: u64, preview_key: u64, factor: u32, pixels: Vec<[f32; 4]>, @@ -251,6 +253,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let view = st.channel_view.clone(); let active = st.active_layer; let (revision, last_damage) = (st.revision, st.last_damage); + let content = revision.wrapping_sub(st.view_revision); let id = st.doc.id.0; if view.is_plain(&st.doc) { if let Some(c) = app.channel_views.get_mut(&id) { @@ -270,12 +273,14 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); - // A live preview can mutate the same Arc in place. Revision and preview_key, not + // A live preview can mutate the same Arc in place. Content revision and preview_key, not // pointer identity, determine whether these flattened pixels are still current. - if let Some(c) = app.channel_views.get_mut(&id) - && c.source.as_ref().is_some_and(|s| s.factor != factor || s.revision != revision || s.preview_key != preview_key) - { - c.source = None; + if let Some(c) = app.channel_views.get_mut(&id) { + if let Some(s) = c.source.as_mut().filter(|s| s.factor == factor && s.content == content && s.preview_key == preview_key) { + s.revision = revision; + } else { + c.source = None; + } } if let Some(c) = app.channel_views.get(&id) && c.key == key @@ -325,12 +330,12 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio c.factor = factor; c.valid = true; if let Some(pixels) = fresh.take() { - c.source = Some(SampledComposite { revision, preview_key, factor, pixels }); + c.source = Some(SampledComposite { revision, content, preview_key, factor, pixels }); } } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - let source = fresh.map(|pixels| SampledComposite { revision, preview_key, factor, pixels }); + let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }); app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); } } @@ -391,14 +396,15 @@ mod tests { app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let id = app.session.active().unwrap().doc.id.0; - let first = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); let original_sample = first.pixels[0]; - let ptr = first.pixels.as_ptr(); + // Mark the cache: a recomposite overwrites it, while a freed buffer's address can come back. + first.pixels[0] = [0.0, 1.0, 0.0, 1.0]; app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); - assert_eq!(ptr, reused.pixels.as_ptr(), "visibility does not recompose the pixels"); + assert_eq!(reused.pixels[0], [0.0, 1.0, 0.0, 1.0], "visibility does not recompose the pixels"); app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ff2622fcbc3..db908d09430 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1386,20 +1386,10 @@ mod tests { app.open_bytes("control.dng", &dng).unwrap(); ctx.run_ui(Default::default(), |ui| crate::camera_raw_ui::show(&mut app, ui.ctx())).textures_delta.clear(); assert!(app.camera_raw.is_some()); - // The raw develops in ProPhoto, so opening it also asks about the working-space - // mismatch. Answer that prompt through the same control path before editing. - assert_eq!(app.ui.dialogs.len(), 1); - let mismatch = app.ui.dialogs.first().unwrap(); - assert_eq!(mismatch.fields["__prefsui"], "mismatch"); - assert_eq!(mismatch.fields["action"], "preserve"); - assert_eq!(mismatch.fields["applied"], "preserve"); - let mismatch_id = mismatch.id; - let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); - assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", update.clone())["ok"], false); - let confirmed = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":mismatch_id})); - assert_eq!(confirmed["ok"], true, "{confirmed}"); - assert_eq!(confirmed["result"]["action"], "preserve"); + // A developed raw has no embedded profile to ask about (`open_prompt`), so no profile + // prompt stands between the open and the Camera Raw dialog. assert!(app.ui.dialogs.is_empty()); + let update = json!({"id":"filter.cameraRaw","params":{"ui":{"set":{"exposure":0.5}}}}); let original = app.session.active().unwrap().doc.clone(); let changed = call(&mut app, &ctx, "ui.menu.invoke", update); assert_eq!(changed["ok"], true, "{changed}"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1adc9051dae..0530031f93c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -157,7 +157,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // effects, extends down and right instead of re-centring. let pinned: Option = ctx.data(|m| m.get_temp(id)); let place = place_id(&d); - let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))); + let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx)); let mut drag = egui::Vec2::ZERO; let mut sizing = false; let area = match start { @@ -221,6 +221,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.set_min_width(w); ui.set_max_width(w); } + // Duplicate Layer… is a fixed small grid (labels + 260 pt fields ≈ 352 pt): + // the generic 380 pt minimum left a dead strip on its right. + if crate::duplicate_layer_ui::owns(&d.fields) { + ui.set_min_width(360.0); + ui.set_max_width(360.0); + } if color_picker { ui.set_width(crate::color_picker_ui::CONTENT_WIDTH); } @@ -299,6 +305,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Image Size and Canvas Size open on Width, selected (#2329). DialogKind::Command if fields.contains_key("__sizing") => first_field_focused(ui, id, |ui| crate::sizing::body(ui, &mut fields)), DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields), + DialogKind::Command if crate::blur_pins::owns(&fields) => crate::blur_pins::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__filter") => { // Color Settings: the monitor profile in use can change while it is open. if fields.get("__command").and_then(Value::as_str) == Some("edit.colorSettings") { @@ -623,6 +630,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } + // Cancel on a profile prompt opens neither its file nor the ones still to come. + if crate::open_prompt::owns(&closed.fields) { + crate::open_prompt::answer(app, None)?; + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; diff --git a/crates/ui-egui/src/duplicate_layer_ui.rs b/crates/ui-egui/src/duplicate_layer_ui.rs index b1e7d6d9943..c1b76c0eadc 100644 --- a/crates/ui-egui/src/duplicate_layer_ui.rs +++ b/crates/ui-egui/src/duplicate_layer_ui.rs @@ -18,11 +18,16 @@ pub fn open(app: &mut PhotocraftApp) -> Result { fields.insert(MARK.into(), json!(true)); fields.insert("__command".into(), json!("layer.duplicate")); fields.insert("destination".into(), json!(index)); + // Photoshop's document-`Name` starts empty; the engine defaults it when empty. + fields.insert("documentName".into(), json!("")); fields.insert("document".into(), json!(st.doc.id.0)); fields.insert("selected".into(), json!(selected.iter().map(|id| id.0).collect::>())); // Several selected layers keep their names, as in Photoshop's Duplicate Layers. if selected.len() > 1 { fields.insert("__label".into(), json!("Duplicate Layers…")); + } else if layer.is_group() { + fields.insert("__label".into(), json!("Duplicate Group…")); + fields.insert("name".into(), json!(st.doc.copy_name(&layer.name))); } else { fields.insert("__label".into(), json!("Duplicate Layer…")); fields.insert("name".into(), json!(st.doc.copy_name(&layer.name))); @@ -32,9 +37,14 @@ pub fn open(app: &mut PhotocraftApp) -> Result { } pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { - let documents: Vec<(usize, String)> = app.session.documents().iter().enumerate().map(|(i, d)| (i, d.doc.name.clone())).collect(); - let options: Vec<(usize, &str)> = documents.iter().map(|(i, n)| (*i, n.as_str())).collect(); - let mut destination = fields.get("destination").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + let documents: Vec<(String, String)> = app.session.documents().iter().enumerate().map(|(i, d)| (i.to_string(), d.doc.name.clone())).collect(); + let mut options: Vec<(String, &str)> = documents.iter().map(|(k, n)| (k.clone(), n.as_str())).collect(); + options.push(("new".to_string(), tl!("New"))); + let mut destination = match fields.get("destination") { + Some(Value::String(s)) if s == "new" => "new".to_string(), + Some(Value::Number(n)) => n.to_string(), + _ => String::new(), + }; egui::Grid::new("duplicate-layer").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { if let Some(name) = fields.get("name").and_then(Value::as_str) { let mut name = name.to_string(); @@ -48,8 +58,20 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map() { + fields.insert("destination".into(), json!(i)); + } } pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { @@ -60,16 +82,29 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + if let Some(doc_name) = fields.get("documentName").and_then(Value::as_str).filter(|n| !n.is_empty()) { + params.insert("documentName".into(), json!(doc_name)); + } + params.insert("document".into(), json!("new")); + app.run_command("layer.duplicate", Value::Object(params)) + } + Some(Value::Number(n)) => { + let destination = + n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < app.session.documents().len()).ok_or("no destination document")?; + if destination == index { + app.run_command("layer.duplicate", Value::Object(params)) + } else { + params.insert("document".into(), json!(destination)); + app.run_command("layer.copyToDocument", Value::Object(params)) + } + } + _ => Err("no destination document".into()), } } @@ -114,6 +149,42 @@ mod tests { assert!(names(&app, 1).contains(&"Ink copy".to_string())); } + #[test] + fn a_new_destination_makes_a_named_document_holding_just_the_copy() { + let mut app = make_app(); + let dialog = invoke(&mut app); + let fields = &mut app.ui.dialog_mut(dialog).unwrap().fields; + fields.insert("destination".into(), json!("new")); + fields.insert("documentName".into(), json!("Card")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[1].doc.name, "Card"); + assert_eq!(app.session.documents()[0].doc.layer_count(), 2, "the source is untouched"); + assert!(names(&app, 1).contains(&"Ink copy".to_string())); + } + + #[test] + fn a_new_destination_without_a_name_defaults_it_and_refuses_a_dead_index() { + let mut app = make_app(); + let dialog = invoke(&mut app); + app.ui.dialog_mut(dialog).unwrap().fields.insert("destination".into(), json!("new")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents()[1].doc.name, "Poster copy"); + let dialog = invoke(&mut app); + app.ui.dialog_mut(dialog).unwrap().fields.insert("destination".into(), json!(7)); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err()); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn a_single_group_gets_the_group_title() { + let mut app = make_app(); + app.session.execute("layer.new.group", json!({"name": "Pack"})).unwrap(); + let _dialog = invoke(&mut app); + assert_eq!(app.ui.dialogs[0].fields["__label"], "Duplicate Group…"); + } + #[test] fn a_changed_document_refuses_and_direct_runs_stay_dialog_free() { let mut app = make_app(); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 8fa630049ab..a322ee108de 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -3,6 +3,8 @@ //! pixel count, so the dialog stays instant on 36 MP documents. use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Mutex; use photocraft_doc::Document; use serde_json::{Map, Value, json}; @@ -78,7 +80,7 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re let id = open(app)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { d.fields.insert("__layer".into(), json!(layer.0)); - d.fields.insert("__label".into(), json!(format!("Export As: {}", ldoc.name))); + d.fields.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); d.fields.insert("__w".into(), json!(ldoc.size.width)); d.fields.insert("__h".into(), json!(ldoc.size.height)); } @@ -187,14 +189,17 @@ fn batch_paths(path: &str, docs: &[Document]) -> Vec { } /// The document a dialog exports: the whole image, or one layer (`__layer`). -fn source_document(app: &PhotocraftApp, f: &Map) -> Result, String> { - let st = app.session.active().ok_or("no document")?; +fn source_document_from(doc: Arc, f: &Map) -> Result, String> { match f.get("__layer").and_then(Value::as_u64) { - Some(id) => photocraft_engine::layer_menu_cmds::layer_document(&st.doc, photocraft_doc::LayerId(id)).map(Arc::new).map_err(|e| e.to_string()), - None => Ok(st.doc.clone()), + Some(id) => photocraft_engine::layer_menu_cmds::layer_document(&doc, photocraft_doc::LayerId(id)).map(Arc::new).map_err(|e| e.to_string()), + None => Ok(doc), } } +fn source_document(app: &PhotocraftApp, f: &Map) -> Result, String> { + source_document_from(app.session.active().ok_or("no document")?.doc.clone(), f) +} + fn s(f: &Map, k: &str) -> String { f.get(k).and_then(Value::as_str).unwrap_or_default().to_string() } @@ -281,16 +286,142 @@ fn settings(f: &Map) -> ExportSettings { } } -/// Estimated size (bytes) from a ≤512 px proxy encode, scaled by pixel count. -fn estimate(app: &PhotocraftApp, doc: &Document, f: &Map) -> Option { - let export = app.services.export.as_ref()?; - let max = if s_fmt(f) == "ico" { 256 } else { 512 }; - let proxy = export_document(doc, f, Some(max)).ok()?; - let (bytes, _) = export(&proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?; +/// The preview and size estimate share a single reduced document. Resizing every layer twice is +/// especially expensive for layered PSDs, even when the canvas itself is small. +fn preview_proxy(doc: &Document, f: &Map) -> Result { + export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 })) +} + +fn scaled_estimate(bytes: usize, doc: &Document, proxy: &Document, f: &Map) -> u64 { let scale = n(f, "scale", 100.0) / 100.0; let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale; let small = (proxy.size.width as f64 * proxy.size.height as f64).max(1.0); - Some((bytes.len() as f64 * full / small) as u64) + (bytes as f64 * full / small) as u64 +} + +/// Estimated size (bytes) from the already prepared proxy encode, scaled by pixel count. +fn estimate(app: &PhotocraftApp, doc: &Document, proxy: &Document, f: &Map) -> Option { + let export = app.services.export.as_ref()?; + let (bytes, _) = export(proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?; + Some(scaled_estimate(bytes.len(), doc, proxy, f)) +} + +struct PreviewPixels { + estimate: Option, + width: usize, + height: usize, + rgba: Vec, +} + +fn preview_pixels(proxy: &Document, estimate: Option) -> PreviewPixels { + let img = photocraft_compose::thumbnail(proxy, 360); + PreviewPixels { estimate, width: img.width as usize, height: img.height as usize, rgba: img.pixels } +} + +fn preview_sync(app: &PhotocraftApp, doc: &Document, f: &Map) -> Result { + let proxy = preview_proxy(doc, f)?; + Ok(preview_pixels(&proxy, estimate(app, doc, &proxy, f))) +} + +#[derive(Clone)] +struct CachedPreview { + signature: String, + estimate: Option, + texture: Option>, +} + +fn cache_preview(ui: &mut egui::Ui, key: egui::Id, signature: String, result: Result) -> CachedPreview { + let preview = match result { + Ok(pixels) => { + let image = egui::ColorImage::from_rgba_unmultiplied([pixels.width, pixels.height], &pixels.rgba); + let texture = Arc::new(ui.ctx().load_texture("export-preview", image, egui::TextureOptions::LINEAR)); + CachedPreview { signature, estimate: pixels.estimate, texture: Some(texture) } + } + Err(error) => { + log::warn!("Export As preview failed: {error}"); + CachedPreview { signature, estimate: None, texture: None } + } + }; + ui.data_mut(|d| d.insert_temp(key, preview.clone())); + preview +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone)] +struct PendingPreview { + key: egui::Id, + signature: String, + result: Arc>>>, +} + +#[cfg(not(target_arch = "wasm32"))] +fn background_preview_needed(doc: &Document) -> bool { + doc.walk().len() > 32 || doc.size.area() > 16_000_000 +} + +/// Native app: keep the UI responsive while a many-layer document is reduced and encoded. +/// A single pending job per document also avoids starting a worker for every slider tick. +#[cfg(not(target_arch = "wasm32"))] +fn poll_or_start_preview( + ui: &mut egui::Ui, + key: egui::Id, + signature: &str, + doc: Arc, + fields: &Map, +) -> Option> { + let pending_key = egui::Id::new(("export-preview-pending", doc.id.0)); + let pending: Option = ui.data(|d| d.get_temp(pending_key)); + if let Some(pending) = pending { + let result = pending.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + ui.data_mut(|d| d.remove::(pending_key)); + if pending.key == key && pending.signature == signature { + return Some(result); + } + } + + let result = Arc::new(Mutex::new(None)); + let shared = result.clone(); + let ctx = ui.ctx().clone(); + let fields = fields.clone(); + let started = std::thread::Builder::new().name("export-preview".into()).spawn(move || { + let prepared = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); + let proxy = preview_proxy(&source, &fields)?; + let estimate = estimate_native(&source, &proxy, &fields); + Ok::<_, String>(preview_pixels(&proxy, estimate)) + })) + .unwrap_or_else(|_| Err("preview worker panicked".into())); + *shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(prepared); + ctx.request_repaint(); + }); + match started { + Ok(_) => { + ui.data_mut(|d| d.insert_temp(pending_key, PendingPreview { key, signature: signature.to_string(), result })); + None + } + Err(error) => Some(Err(format!("could not start preview worker: {error}"))), + } +} + +/// The native exporter uses this same IO encoder. Keep the entire estimate off the UI thread. +#[cfg(not(target_arch = "wasm32"))] +fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Option { + let settings = settings(f); + let mut options = photocraft_io::ExportOptions::default(); + if let Some(quality) = settings.jpeg_quality { + options.encode.jpeg_quality = quality; + } + options.encode.webp_lossless = settings.webp_lossless; + if let Some(quality) = settings.webp_quality { + options.encode.webp_quality = quality; + } + options.tiff_layers = settings.tiff_layers; + options.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; + options.tga_bits = settings.tga_bits; + options.image_previews = settings.image_previews; + options.psd_maximize_compatibility = settings.psd_maximize_compatibility; + let bytes = photocraft_io::export(proxy, &format!("estimate.{}", s_fmt(f)), &options).ok()?.bytes; + Some(scaled_estimate(bytes.len(), doc, proxy, f)) } pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { @@ -374,28 +505,40 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, Arc)> = ui.data(|d| d.get_temp(key)); - let (size, tex) = match cached.filter(|c| c.0 == sig) { - Some((_, size, tex)) => (size, tex), + let cached: Option = ui.data(|d| d.get_temp(key)); + let preview = match cached.filter(|cached| cached.signature == sig) { + Some(cached) => Some(cached), None => { - let src = source_document(app, f).unwrap_or_else(|_| doc.clone()); - let size = estimate(app, &src, f); - let img = photocraft_compose::thumbnail(&export_document(&src, f, Some(360)).unwrap_or_else(|_| (*src).clone()), 360); - let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = Arc::new(ui.ctx().load_texture("export-preview", color, egui::TextureOptions::LINEAR)); - ui.data_mut(|d| d.insert_temp(key, (sig, size, tex.clone()))); - (size, tex) + #[cfg(not(target_arch = "wasm32"))] + let result = if background_preview_needed(&doc) { + poll_or_start_preview(ui, key, &sig, doc.clone(), f) + } else { + let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); + Some(preview_sync(app, &source, f)) + }; + #[cfg(target_arch = "wasm32")] + let result = { + let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); + Some(preview_sync(app, &source, f)) + }; + result.map(|result| cache_preview(ui, key, sig, result)) } }; let side = 300.0; let (r, _) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover()); crate::widgets::checker(ui.painter(), r, 8.0); - let ts = tex.size_vec2(); - let k = (side / ts.x.max(ts.y)).min(1.0e3); - let ir = egui::Rect::from_center_size(r.center(), ts * k); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + if let Some(tex) = preview.as_ref().and_then(|preview| preview.texture.as_ref()) { + let ts = tex.size_vec2(); + let k = (side / ts.x.max(ts.y)).min(1.0e3); + let ir = egui::Rect::from_center_size(r.center(), ts * k); + ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } else if preview.is_none() { + ui.put(egui::Rect::from_center_size(r.center(), egui::vec2(24.0, 24.0)), egui::Spinner::new()); + } else { + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), egui::FontId::proportional(12.0), t.text_dim); + } ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let est = size.map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); + let est = preview.as_ref().and_then(|preview| preview.estimate).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); ui.label(egui::RichText::new(format!("{} ≈ {est}", s_fmt(f).to_uppercase())).color(t.text_dim).size(11.5)); }); }); @@ -656,6 +799,54 @@ mod tests { assert_eq!(scaled_size(10.0, 300.0, 0.01), (1.0, 3.0)); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn layered_preview_runs_outside_the_ui_frame() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 16})).unwrap(); + for _ in 0..33 { + app.run("layer.new.layer", json!({})).unwrap(); + } + let doc = app.session.active().unwrap().doc.clone(); + assert!(background_preview_needed(&doc)); + let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(100))]); + let ctx = egui::Context::default(); + let key = egui::Id::new("layered-preview-test"); + let mut result = None; + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + }); + }); + output.textures_delta.clear(); + assert!(result.is_none(), "the first UI frame must only start the worker"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + }); + }); + output.textures_delta.clear(); + if let Some(preview) = result { + let preview = preview.unwrap(); + assert_eq!((preview.width, preview.height), (32, 16)); + assert_eq!(preview.rgba.len(), 32 * 16 * 4); + assert!(preview.estimate.is_some_and(|bytes| bytes > 0)); + break; + } + assert!(std::time::Instant::now() < deadline, "background preview did not complete"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let tiff_fields = Map::from_iter([("format".into(), json!("tif")), ("scale".into(), json!(100))]); + let proxy = preview_proxy(&doc, &tiff_fields).unwrap(); + let options = photocraft_io::ExportOptions { tiff_layers: true, xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; + let actual_bytes = photocraft_io::export(&proxy, "estimate.tif", &options).unwrap().bytes.len(); + assert_eq!(estimate_native(&doc, &proxy, &tiff_fields), Some(scaled_estimate(actual_bytes, &doc, &proxy, &tiff_fields))); + } + #[test] fn switching_export_formats_uses_independent_quality_preferences() { let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() }; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index c3f41b487d5..5f7dfc5c981 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -739,7 +739,7 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(written.borrow().len(), 2); assert_eq!(written.borrow()[0], "/out/boards.jpg"); - // The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows). + // The sibling path is built with `Path::join`, so Windows turns the separator into `\`. let second = written.borrow()[1].replace('\\', "/"); assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 492da515b16..d67e56dbf02 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -36,12 +36,19 @@ pub enum DropTarget { /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. -pub(crate) fn with_missing_fonts(app: &PhotocraftApp, mut warnings: Vec) -> Vec { - if let Some(st) = app.session.active() { - for w in photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc) { - if !warnings.contains(&w) { - warnings.push(w); - } +pub(crate) fn with_missing_fonts(app: &PhotocraftApp, warnings: Vec) -> Vec { + match app.session.active() { + Some(st) => with_missing_fonts_of(&st.doc, warnings), + None => warnings, + } +} + +/// [`with_missing_fonts`] for `doc` itself: a file held back for its profile prompt +/// (`open_prompt`) isn't the active document yet. +pub(crate) fn with_missing_fonts_of(doc: &photocraft_doc::Document, mut warnings: Vec) -> Vec { + for w in photocraft_engine::type_extra_cmds::missing_font_warnings(doc) { + if !warnings.contains(&w) { + warnings.push(w); } } warnings @@ -71,9 +78,7 @@ impl PhotocraftApp { crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes))?; return Ok(Vec::new()); } - let warnings = self.open_bytes(&display_name(path), bytes)?; - self.opened_from(path); - Ok(warnings) + self.open_bytes_from(&display_name(path), bytes, Some(path)) } /// Record that the active document was just opened from `path`: File › Save writes back to it diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 4c19b8943af..f4dacbc5757 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -435,6 +435,10 @@ pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map) { } } apply_command_defaults(app, &command, fields); + // The schema has no default for the Blur Gallery's pins: start again from the opening one. + if command == crate::blur_pins::COMMAND { + crate::blur_pins::reset(fields); + } } pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { @@ -454,6 +458,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { } restore_remembered(app, command, spec.params, &mut fields); apply_command_defaults(app, command, &mut fields); + if command == crate::blur_pins::COMMAND { + crate::blur_pins::init(app, &mut fields); + } if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -545,6 +552,14 @@ pub(crate) fn label(key: &str) -> String { } pub(crate) fn source_label(key: &str) -> String { + // Photoshop's name for the localized colour matching (Replace Color, Color Range). + if key == "localized" { + return "Localized Color Clusters".into(); + } + // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. + if key.contains(' ') { + return key.to_string(); + } // camelCase → "Camel Case" let mut s = String::new(); for (i, ch) in key.chars().enumerate() { @@ -616,6 +631,8 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - && let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + // Where it was picked: Localized Color Clusters match the colour near this point (#4457). + d.fields.insert("points".into(), json!([[x.floor(), y.floor()]])); } true } @@ -645,6 +662,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { f.insert(p.key, json!(count)); continue; } + // Replace Color's Range only applies with Localized Color Clusters on, as in Photoshop. + let enabled = !(cmd == REPLACE_COLOR && p.key == "range") || f.get("localized").and_then(Value::as_bool) == Some(true); let unit = if is_pixel_param_of(&cmd, &p.key) { "px" } else if p.key == "angle" { @@ -654,28 +673,33 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } else { "" }; - if uses_angle_dial(&cmd, &p.key) { - crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); - ui.add_space(4.0); - } else if uses_logarithmic_slider(min, max) { - // Keep one numeric field for direct entry and one log-scaled slider for - // wide ranges. slider_row would add a second, linear slider (#146). - ui.horizontal(|ui| { - ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); - }); - }); - let mut lv = v.clamp(min, max).ln(); - if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { - v = lv.exp(); + ui.scope(|ui| { + if !enabled { + ui.disable(); } - ui.add_space(4.0); - } else { - // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; - crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); - } + if uses_angle_dial(&cmd, &p.key) { + crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); + ui.add_space(4.0); + } else if uses_logarithmic_slider(min, max) { + // Keep one numeric field for direct entry and one log-scaled slider for + // wide ranges. slider_row would add a second, linear slider (#146). + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); + }); + }); + let mut lv = v.clamp(min, max).ln(); + if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { + v = lv.exp(); + } + ui.add_space(4.0); + } else { + // A double-click on the slider puts it back to the filter's default. + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); + } + }); // Small ranges (0–1 thresholds and centres) keep the two decimals their field shows. let scale = if max - min <= 10.0 { 100.0 } else { 10.0 }; f.insert(p.key, json!((v * scale).round() / scale)); @@ -893,6 +917,10 @@ pub(crate) fn preview_document_for_target( } } } + // Blur Gallery pins carry their blur in document pixels too. + if command.starts_with("filter.blurGallery.") { + crate::blur_pins::scale_for_proxy(&mut p, run_k); + } s.execute(command, p).ok()?; s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } @@ -951,6 +979,15 @@ pub struct FilterPreviewKey { mod tests { use super::*; + /// Preset names such as "iPhone 14 Pro Max" (the artboard dialog's choices) are text, not + /// camelCase keys: they used to read "I Phone 14 Pro Max". + #[test] + fn display_names_are_not_split_like_camel_case_keys() { + assert_eq!(source_label("iPhone 14 Pro Max"), "iPhone 14 Pro Max"); + assert_eq!(source_label("Web 1920"), "Web 1920"); + assert_eq!(source_label("centerX"), "Center X"); + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. @@ -2169,6 +2206,8 @@ mod tests { assert!(color_eyedropper_armed(&app)); assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // #4457: where it was picked, for Localized Color Clusters. + assert_eq!(app.ui.dialogs.last().unwrap().fields["points"], json!([[4.0, 4.0]])); // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); @@ -2199,4 +2238,37 @@ mod tests { h.run_steps(2); assert!(!color_eyedropper_armed(h.state())); } + + /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point + /// and both settings reach the command. + #[test] + fn replace_color_localized_color_clusters() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + // Left half red, right half red too: localized on the left point, the right half stays. + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 10, "background": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + let _ = h.get_by_label("Localized Color Clusters"); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0)); + h.get_by_label("Localized Color Clusters").click(); + h.run_steps(2); + let f = h.state().ui.dialogs.last().unwrap().fields.clone(); + assert_eq!((f["localized"].clone(), f["points"].clone()), (json!(true), json!([[2.0, 5.0]]))); + // The command with the dialog's fields, small range and a hue shift: only near (2, 5). + let mut p: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + p.insert("range".into(), json!(10)); + p.insert("hue".into(), json!(120)); + h.state_mut().run(REPLACE_COLOR, Value::Object(p)).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + let px = |x: i32| doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(x, 5, x + 1, 6)); + assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2)); + assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); + } } diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index bcf0e695161..046d4ac2a73 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -527,6 +527,20 @@ impl GpuCanvas { doc: &photocraft_doc::Document, damage: Option, display: Option<&photocraft_engine::display_color::CanvasDisplay>, + ) -> Refresh { + self.refresh_with(key, doc, damage, display, false) + } + + /// [`GpuCanvas::refresh`], except that with `defer`, a refresh of at least [`DEFER_PX`] the + /// CPU compositor would draw is left to the caller (`background_redraw`): the texture is made + /// ready (keeping what it shows when it already fits) and the kind is [`REFRESH_DEFERRED`]. + pub fn refresh_with( + &self, + key: u64, + doc: &photocraft_doc::Document, + damage: Option, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + defer: bool, ) -> Refresh { if let Some(f) = self.fault() { // Nothing reaches the GPU any more; the app switches to the CPU canvas. @@ -564,6 +578,12 @@ impl GpuCanvas { } Err(e) => out.fallback = Some(e.0), } + let px = region.width() as u64 * region.height() as u64; + if defer && px >= DEFER_PX && self.prepare_texture(key, doc).is_some() { + out.kind = REFRESH_DEFERRED; + out.px = px; + return out; + } if let Some(r) = damage { if r.is_empty() { out.kind = "rect"; @@ -591,22 +611,8 @@ impl GpuCanvas { /// never needs a full-size float composite or texture-format copy in memory. pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { let size = [doc.size.width, doc.size.height]; - if size[0] == 0 || size[1] == 0 || !self.health.is_ok() { - return; - } - let format = self.format_for(doc.depth, size); + let Some(format) = self.prepare_texture(key, doc) else { return }; let (device, queue) = (&self.rs.device, &self.rs.queue); - { - let mut renderer = self.rs.renderer.write(); - let Some(res) = renderer.callback_resources.get_mut::() else { return }; - if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { - let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { - res.docs.remove(&key); - return; - }; - res.docs.insert(key, tex); - } - } let mut rgba = Vec::new(); let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { if !self.health.is_ok() { @@ -633,6 +639,26 @@ impl GpuCanvas { } } + /// Make sure document `key` has a display texture of `doc`'s size and format, keeping the one + /// it has when that fits. Returns its format, or `None` when it can't be created. + pub fn prepare_texture(&self, key: u64, doc: &photocraft_doc::Document) -> Option { + let size = [doc.size.width, doc.size.height]; + if size[0] == 0 || size[1] == 0 || !self.health.is_ok() { + return None; + } + let format = self.format_for(doc.depth, size); + let mut renderer = self.rs.renderer.write(); + let res = renderer.callback_resources.get_mut::()?; + if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { + let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else { + res.docs.remove(&key); + return None; + }; + res.docs.insert(key, tex); + } + Some(format) + } + /// Free textures, display LUTs and their signatures for documents not in `live`. pub fn retain(&self, live: &[u64]) { let mut renderer = self.rs.renderer.write(); @@ -764,6 +790,16 @@ fn texture_buffer<'a>( } } +/// A CPU composite `buf` as texels of a `format` canvas texture, in `display`'s encoding: what +/// [`GpuCanvas::upload_rect`] takes. Runs anywhere (a redraw's worker converts its own pieces). +pub fn display_texels( + format: wgpu::TextureFormat, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + buf: &photocraft_compose::Buffer, +) -> Vec { + texels(format, &texture_buffer(display, buf).px) +} + /// What a [`GpuCanvas::refresh`] did. #[derive(Clone, Debug, Default)] pub struct Refresh { @@ -1151,6 +1187,13 @@ pub const OVER_CANVAS_BUDGET: &str = "the canvas exceeds the GPU texture budget; /// was created, and the caller draws the CPU canvas instead. pub const REFRESH_OVER_BUDGET: &str = "over-budget"; +/// [`Refresh::kind`] of a CPU refresh left to the caller ([`GpuCanvas::refresh_with`]). +pub const REFRESH_DEFERRED: &str = "deferred"; + +/// The smallest CPU refresh (pixels) [`GpuCanvas::refresh_with`] defers: below it, the +/// synchronous composite takes a fraction of a second even with many layers. +pub const DEFER_PX: u64 = 1 << 22; + /// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter /// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set /// covers the whole canvas at full resolution, so its size follows the declared canvas, not the diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs new file mode 100644 index 00000000000..92c458e37fa --- /dev/null +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -0,0 +1,60 @@ +//! #2966: a click on a History panel state returns the document there with one command +//! (`history.selectState`), not one Undo / Redo per state, so the canvas refreshes once and only +//! where the jump changed pixels. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; + +/// Open, then New Layer, Fill, New Layer, Fill. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + for (cmd, p) in + [("layer.new.layer", json!({})), ("edit.fill", json!({"color": "#ff0000"})), ("layer.new.layer", json!({})), ("edit.fill", json!({"color": "#00ff00"}))] + { + app.run(cmd, p).unwrap(); + } + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + history(app, ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn state(h: &Harness<'_, PhotocraftApp>) -> (usize, usize, u64) { + let st = h.state().session.active().unwrap(); + (st.history.past_len(), st.history.redo_labels().count(), st.revision) +} + +#[test] +fn clicking_a_state_jumps_there_with_one_command() { + let mut h = harness(); + let (past, redo, rev) = state(&h); + assert_eq!((past, redo), (4, 0)); + // Back three states: one command, one revision (the canvas sees one change). + h.get_all_by_label("New Layer").next().expect("the first New Layer row").click(); + h.run_steps(2); + assert_eq!(state(&h), (1, 3, rev + 1)); + assert_eq!(h.state().perf.last_command, "history.selectState"); + // Forward to the last (dimmed) state, again in one step. + h.get_all_by_label("Fill").last().expect("the last Fill row").click(); + h.run_steps(2); + assert_eq!(state(&h), (4, 0, rev + 2)); + // The current state: nothing to do. + h.get_all_by_label("Fill").last().expect("the last Fill row").click(); + h.run_steps(2); + assert_eq!(state(&h), (4, 0, rev + 2)); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9ee090d8302..805c20f76a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -559,6 +559,9 @@ Blur Rozostření Blur Gallery Galerie rozostření Field Blur… Rozostření pole… +# Field Blur pins (#3373) + No pin selected Není vybrán žádný špendlík + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík. Iris Blur… Rozostření clonou… Tilt-Shift… Tilt-shift… Path Blur… Rozostření podél cesty… @@ -2209,6 +2212,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Není k dispozici žádná cesta, ze které by šlo vytvořit výběr: nakreslete ji nástrojem Pero nebo ji vyberte na panelu Cesty. Cancel opening Zrušit otevírání Cancelled opening {name} Otevírání {name} zrušeno + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nemá vložený profil {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ má vložený barevný profil, který neodpovídá aktuálnímu pracovnímu prostoru barev {mode}. + Embedded: {embedded} Vložený: {embedded} + Working: {working} Pracovní: {working} Developing {name} Vyvolávání {name} Cancelled {label} Zrušeno: {label} Esc to cancel Esc pro zrušení @@ -2428,6 +2435,7 @@ cameraRaw Color Barvy Violet Fialová Reset Obnovit Large document: redrawing on the CPU Velký dokument: překreslování na CPU + Redrawing Překreslování This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače. @@ -3510,6 +3518,7 @@ curves Smooth Vyhladit Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu Clear Brush Strokes Vymazat tahy štětce Show rulers Zobrazit pravítka + Presentation Prezentace # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete @@ -3665,8 +3674,6 @@ curves Smooth Vyhladit fallback náhradní # Composed labels and Navigator zoom context - The document does not have an embedded colour profile. Working space: {working}. Dokument nemá vložený barevný profil. Pracovní prostor barev: {working}. - The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument má vložený barevný profil, který neodpovídá pracovnímu prostoru barev.\nVložený: {embedded}\nPracovní: {working} Importing {what} from {file}… Importují se {what} ze souboru {file}… brushes štětce gradients přechody @@ -3724,6 +3731,8 @@ navigator Zoom Zvětšení @plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu + File Naming Pojmenování souborů + Starting serial Počáteční pořadové číslo # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Vložit zastavení… Save Set… Uložit sadu… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 649daa5d863..88ef9576754 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -558,6 +558,9 @@ Blur Weichzeichnen Blur Gallery Weichzeichnen-Galerie Field Blur… Feldweichzeichner… +# Field Blur pins (#3373) + No pin selected Kein Pin ausgewählt + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin. Iris Blur… Irisweichzeichner… Tilt-Shift… Tilt-Shift… Path Blur… Pfadweichzeichner… @@ -1708,7 +1711,7 @@ Layer Knocks Out Drop Shadow Ebene stanzt den Schlagschatten aus Legal Legal Letter Letter - Light Light + Light Hell Line Tool Linien-Werkzeug Linear Linear Linear Burn Linear nachbelichten @@ -2179,6 +2182,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Kein Pfad für eine Auswahl vorhanden: zeichne einen mit dem Stift-Werkzeug oder wähle einen im Pfade-Bedienfeld. Cancel opening Öffnen abbrechen Cancelled opening {name} Öffnen von {name} abgebrochen + The document “{name}” does not have an embedded {mode} profile. Das Dokument „{name}“ hat kein eingebettetes {mode}-Profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Das Dokument „{name}“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen {mode}-Arbeitsfarbraum passt. + Embedded: {embedded} Eingebettet: {embedded} + Working: {working} Arbeitsfarbraum: {working} Developing {name} {name} wird entwickelt Cancelled {label} {label} abgebrochen Esc to cancel Esc zum Abbrechen @@ -2467,6 +2474,7 @@ layerLabel No Color Keine Farbe Fuchsia Fuchsia Violet Violett Large document: redrawing on the CPU Großes Dokument: Neuzeichnen auf der CPU + Redrawing Neuzeichnen This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers. @@ -2702,6 +2710,7 @@ curves Smooth Glätten Paint over hair or fur on the image; Alt paints the other mode Über Haare oder Fell im Bild malen; Alt malt im anderen Modus Clear Brush Strokes Pinselstriche löschen Show rulers Lineale anzeigen + Presentation Präsentation # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen @@ -2739,6 +2748,360 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau +# Status messages, system info and tool names + Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url}) + Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild + Graphics adapter: {adapter} Grafikadapter: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Treiber: {driver} + GPU backend preference: {backend} Bevorzugtes GPU-Backend: {backend} + Selected at launch: {backend} Beim Start gewählt: {backend} + Image compositor: {processor} Bildkompositor: {processor} + Window renderer: {renderer} Fensterdarstellung: {renderer} + Graphics adapter Grafikadapter + Platform: {os} {arch} Plattform: {os} {arch} + Monitor profile: {profile} Monitorprofil: {profile} + Opened {url} {url} geöffnet + {index} of {total} {index} von {total} + Export As: {name} Exportieren als: {name} + Color Sampler Tool Farbaufnahme-Werkzeug + Vertical Type Tool Vertikales Text-Werkzeug + Patch Tool Ausbessern-Werkzeug + Grid view Rasteransicht + List view Listenansicht + Proof Setup… Proof einrichten… + 32-bit Preview Options 32-Bit-Vorschauoptionen +# Shortcut editor commands without a menu item + Toggle Layer Mask Overlay Ebenenmasken-Überlagerung umschalten + Increase Type Size Schriftgröße erhöhen + Decrease Type Size Schriftgröße verringern + Increase Type Size by 10 Schriftgröße um 10 erhöhen + Decrease Type Size by 10 Schriftgröße um 10 verringern + Make Selection from Path Auswahl aus Pfad erstellen + Target Composite Channel Gesamtkanal als Ziel festlegen + Target Channel 3 Kanal 3 als Ziel festlegen + Target Channel 4 Kanal 4 als Ziel festlegen + Target Channel 5 Kanal 5 als Ziel festlegen + Target Channel 6 Kanal 6 als Ziel festlegen + Target Channel 7 Kanal 7 als Ziel festlegen + Target Channel 8 Kanal 8 als Ziel festlegen + Target Channel 9 Kanal 9 als Ziel festlegen + Switch Foreground and Background Colors Vorder- und Hintergrundfarbe vertauschen + Default Foreground and Background Colors Standardfarben für Vorder- und Hintergrund + Fill with Foreground Color Mit Vordergrundfarbe füllen + Fill with Background Color Mit Hintergrundfarbe füllen + Fill with Foreground Color, Preserve Transparency Mit Vordergrundfarbe füllen, Transparenz erhalten + Fill with Background Color, Preserve Transparency Mit Hintergrundfarbe füllen, Transparenz erhalten + Decrease Brush Size Pinselgröße verringern + Increase Brush Size Pinselgröße erhöhen + Decrease Brush Hardness Pinselhärte verringern + Increase Brush Hardness Pinselhärte erhöhen + Temporary Vorübergehend + Space Leertaste + Hand Tool (hold) Hand-Werkzeug (gedrückt halten) + Zoom In (hold, drag to scrub) Vergrößern (gedrückt halten, zum Zoomen ziehen) + Zoom Out (hold) Verkleinern (gedrückt halten) + Erase with Brush or Pencil (hold) Mit Pinsel oder Buntstift radieren (gedrückt halten) +# Document Info and color profiles + Author Title Position des Urhebers + Copyright Status Urheberrechtsstatus + Unknown Unbekannt + Copyrighted Urheberrechtlich geschützt + Public Domain Gemeinfrei + Copyright Url Urheberrechts-URL + Keywords Stichwörter + Title Titel + Profile Profil + Working Arbeitsfarbraum + Working-cmyk Arbeits-CMYK + Coated-cmyk CMYK (gestrichen) + Linear-srgb Lineares sRGB + Gray-gamma-2.2 Grau (Gamma 2,2) + Adobe-rgb-compat Adobe-RGB-kompatibel + Prophoto-compat ProPhoto-kompatibel + Display-p3 Display P3 + Intent Renderpriorität + Bpc Schwarzpunktkompensierung + Simulate Paper Papierfarbe simulieren +# Color Lookup, Shadows/Highlights, channels and Calculations + Warming Filter (85) Wärmefilter (85) + Lut LUT + Cool Kühl + Teal Orange Türkis-Orange + Faded Film Verblasster Film + Day For Night Tag für Nacht + Mono Contrast Mono-Kontrast + Cross Process Kreuzentwicklung + Shadow Amount Stärke der Tiefen + Shadow Tone Tonbreite der Tiefen + Shadow Radius Radius der Tiefen + Highlight Amount Stärke der Lichter + Highlight Tone Tonbreite der Lichter + Highlight Radius Radius der Lichter + Midtone Mitteltöne + Black Clip Schwarz beschneiden + White Clip Weiß beschneiden + Strength Stärke + Shadow Schatten + Highlight Lichter + Intensity Intensität + Neutralize Neutralisieren + Use Selection In Source Auswahl in der Quelle verwenden + Use Selection In Target Auswahl im Ziel verwenden + Cw Im Uhrzeigersinn + Ccw Gegen den Uhrzeigersinn + Nearest Nächster Nachbar + Linear Dodge Linear abwedeln + Mask Channel Maskenkanal + R G B RGB + Source Channel Quellkanal + Source Invert Quelle invertieren + Result Ergebnis + Source1 Channel Kanal der Quelle 1 + Source1 Invert Quelle 1 invertieren + Source2 Channel Kanal der Quelle 2 + Source2 Invert Quelle 2 invertieren + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 +# Measurement log + Label Beschriftung + Date and Time Datum und Uhrzeit + Scale Units Maßstabseinheiten + Scale Factor Maßstabsfaktor + Area Fläche + Perimeter Umfang + Circularity Kreisförmigkeit + Gray Value (Minimum) Grauwert (Minimum) + Gray Value (Maximum) Grauwert (Maximum) + Gray Value (Mean) Grauwert (Mittelwert) + Gray Value (Median) Grauwert (Median) + Integrated Density Integrierte Dichte + Length Länge + W: B: + A: Wi: +# Lens Correction, Blur Gallery and distortion filters + Pattern Fill… Musterfüllung… + Generic Allgemein + Correct Distortion Verzerrung korrigieren + Correct Vignette Vignette korrigieren + Correct C A Chromatische Aberration korrigieren + Auto Scale Automatisch skalieren + Distortion Verzerrung + Red Cyan Rot/Cyan + Blue Yellow Blau/Gelb + Vignette Amount Stärke der Vignette + Vignette Midpoint Mittelpunkt der Vignette + Edge Extension Kantenerweiterung + Hexagon Sechseck + Pentagon Fünfeck + Heptagon Siebeneck + Octagon Achteck + Blade Curvature Krümmung der Blendenlamellen + Depth Map Tiefenkarte + Focal Distance Fokusabstand + Distribution Verteilung + Uniform Gleichmäßig + Monochromatic Monochrom + Seed Startwert + Spin Drehen + Draft Entwurf + Good Gut + Best Beste + Center X Mitte X + Center Y Mitte Y + Circle Kreis + Star Stern + Cross Kreuz + High Hoch + Low Niedrig + Edge Only Nur Kanten + Overlay Edge Kante überlagern + Focus Fokus + Transition Übergang + Speed Geschwindigkeit + Taper Verjüngung + End X Ende X + End Y Ende Y + Blur Angle Weichzeichnungswinkel + Rectangular To Polar Rechteckig zu Polar + Polar To Rectangular Polar zu Rechteckig + Undefined Areas Undefinierte Bereiche + Wrap Umbrechen + Repeat Wiederholen + Horizontal Only Nur horizontal + Vertical Only Nur vertikal + Generators Generatoren + Wavelength Min Wellenlänge min. + Wavelength Max Wellenlänge max. + Amplitude Min Amplitude min. + Amplitude Max Amplitude max. + Sine Sinus + Ridges Grate + Pond Ripples Teichwellen + Out From Center Von der Mitte nach außen + Around Center Um die Mitte + Reduce Color Noise Farbrauschen reduzieren + Sharpen Details Details schärfen + Remove Jpeg Artifact JPEG-Artefakte entfernen + Max Radius Maximaler Radius + Cell Size Zellengröße + Fine Dots Feine Punkte + Medium Dots Mittlere Punkte + Grainy Dots Körnige Punkte + Coarse Dots Grobe Punkte + Short Lines Kurze Linien + Medium Lines Mittlere Linien + Long Lines Lange Linien + Short Strokes Kurze Striche + Medium Strokes Mittlere Striche + Long Strokes Lange Striche +# Render filters: Flame, Picture Frame, Tree + Flame Type Flammentyp + One Flame Along Path Eine Flamme entlang des Pfads + Multiple Flames Along Path Mehrere Flammen entlang des Pfads + Multiple Flames Path Directions Mehrere Flammen, Pfadrichtungen + Multiple Flames Various Length Mehrere Flammen, unterschiedliche Längen + Candle Light Kerzenlicht + Multiple Flames One Direction Mehrere Flammen, eine Richtung + Randomize Length Länge zufällig variieren + Interval Abstand + Adjust Interval For Loops Abstand für Schleifen anpassen + Use Custom Color Eigene Farbe verwenden + Jag Zacken + Flame Lines Flammenlinien + Flame Bottom Alignment Ausrichtung des Flammenfußes + Flame Style Flammenstil + Violent Heftig + Flat Flach + Flame Shape Flammenform + To Center Zur Mitte + Pointed Spitz + Randomize Shapes Formen zufällig variieren + Fine Fein + Frame Rahmen + Vine With Flowers Ranke mit Blumen + Vine With Leaves Ranke mit Blättern + Ivy Efeu + Roses Rosen + Daisies Gänseblümchen + Berries Beeren + Bamboo Bambus + Waves Wellen + Zigzag Zickzack + Rope Seil + Scallops Muschelrand + Double Line Doppellinie + Snowflakes Schneeflocken + Stars Sterne + Hearts Herzen + Arrangement Anordnung + Thickness Dicke + Vine Color Rankenfarbe + Flower Color Blütenfarbe + Leaf Color Blattfarbe + Base Tree Type Baumtyp + Light Direction Lichtrichtung + Leaves Amount Blattmenge + Leaves Size Blattgröße + Branches Height Asthöhe + Branches Thickness Aststärke + Default Leaves Standardblätter + Leaves Color Blattfarbe + Custom Branch Color Eigene Astfarbe + Branches Color Astfarbe + Flat Shading Flächige Schattierung +# Lens, Lighting Effects, depth and mosaic filters + Lens Objektiv + Prime35 Festbrennweite 35 + Prime105 Festbrennweite 105 + Movie Prime Film-Festbrennweite + Light Type Lichttyp + Point Punkt + Infinite Unendlich + Light X Licht X + Light Y Licht Y + Light Z Licht Z + Target X Ziel X + Target Y Ziel Y + Elevation Höhe + Gloss Glanz + Metallic Metallisch + Ambience Umgebungslicht + White Is High Weiß ist hoch + Ambient Umgebung + Warmth Wärme + Softness Weichheit + Reduce Noise Rauschen reduzieren + Darken Only Nur abdunkeln + Lighten Only Nur aufhellen + Anisotropic Anisotrop + Blocks Blöcke + Pyramids Pyramiden + Depth Mode Tiefenmodus + Random Zufällig + Level Based Stufenbasiert + Solid Front Faces Einfarbige Vorderseiten + Mask Incomplete Blocks Unvollständige Blöcke maskieren + Stylization Stilisierung + Cleanliness Sauberkeit + Bristle Detail Borstendetail + Lighting Beleuchtung + Shine Glanz + Max Offset Maximaler Versatz + Unaltered Unverändert + Lower Untere + Upper Obere + Blast Böe + Stagger Versetzt + From Right Von rechts + From Left Von links + Eliminate Entfernen + Odd Fields Ungerade Halbbilder + Even Fields Gerade Halbbilder + Create By Erzeugen durch + Duplication Duplizieren + Kernel Kern + Input Mode Eingabemodus + Rgb RGB + Hsb HSB + Hsl HSL + Row Order Zeilenreihenfolge + Preserve Erhalten + Squareness Rechteckigkeit +# Find and Replace, image size, Warp and Layer Style blending options + Any Beliebig + Cmyk CMYK + To Nach + Dont Enlarge Nicht vergrößern + Find Suchen + Replace Ersetzen + Change Find Ersetzen und suchen + Case Sensitive Groß-/Kleinschreibung beachten + Whole Word Ganzes Wort + Forward Vorwärts + Based On Basierend auf + General Blending Allgemeine Füllmethode + Advanced Blending Erweiterte Füllmethode + Blend Interior Effects as Group Innere Effekte als Gruppe überblenden + Blend Clipped Layers as Group Ebenen der Schnittmaske als Gruppe überblenden + Transparency Shapes Layer Transparenz bestimmt die Ebenenform + Layer Mask Hides Effects Ebenenmaske blendet Effekte aus + Vector Mask Hides Effects Vektormaske blendet Effekte aus + This Layer: Diese Ebene: + Underlying Layer: Darunterliegende Ebene: + Up Nach oben + Checkerboard Schachbrett + Arc Lower Bogen unten + Arc Upper Bogen oben + Bend Biegen + Horizontal Distortion Horizontale Verzerrung + Vertical Distortion Vertikale Verzerrung + Stretch Strecken + Map Document Dokument zuordnen + Map Path Pfad zuordnen + Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum Remove after each stroke Nach jedem Strich entfernen @@ -2758,6 +3121,8 @@ curves Smooth Glätten @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen + File Naming Dateibenennung + Starting serial Startnummer # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stopp einfügen… Save Set… Set speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ab76e427895..e8889313cb2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1027,7 +1027,6 @@ Choose… Επιλογή… Select Folder… Επιλογή φακέλου… Override Action "Save" Commands Παράκαμψη εντολών αποθήκευσης της ενέργειας - File Naming Ονομασία αρχείων Compatibility Συμβατότητα Include ICC Profile Συμπερίληψη προφίλ ICC Error File Αρχείο σφαλμάτων @@ -1611,6 +1610,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Δεν υπάρχει διαδρομή για δημιουργία επιλογής: σχεδιάστε μία με το εργαλείο Πένα (Pen Tool) ή επιλέξτε μία στο πλαίσιο Διαδρομές. Cancel opening Ακύρωση ανοίγματος Cancelled opening {name} Ακυρώθηκε το άνοιγμα του {name} + The document “{name}” does not have an embedded {mode} profile. Το έγγραφο «{name}» δεν έχει ενσωματωμένο προφίλ {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Το έγγραφο «{name}» έχει ενσωματωμένο χρωματικό προφίλ που δεν ταιριάζει με τον τρέχοντα χώρο εργασίας {mode}. + Embedded: {embedded} Ενσωματωμένο: {embedded} + Working: {working} Χώρος εργασίας: {working} Cancelled {label} Ακυρώθηκε: {label} Esc to cancel Esc για ακύρωση Opened {name} Άνοιξε το {name} @@ -2395,6 +2398,9 @@ Fibers… Ίνες… Field Blur Θόλωση πεδίου Field Blur… Θόλωση πεδίου… +# Field Blur pins (#3373) + No pin selected Δεν έχει επιλεγεί καρφίτσα + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα. File Info Πληροφορίες αρχείου File Settings Ρυθμίσεις αρχείου Fill Path Γέμισμα διαδρομής @@ -3432,6 +3438,7 @@ cameraRaw Yellow Κίτρινο Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή. Knockout Διάτρηση Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU + Redrawing Επανασχεδίαση Median… Διάμεσος… Move Μετακίνηση New Swatch Group… Νέα ομάδα δειγμάτων χρωμάτων… @@ -3758,6 +3765,7 @@ curves Smooth Εξομάλυνση Paint over hair or fur on the image; Alt paints the other mode Βάψτε πάνω σε μαλλιά ή γούνα στην εικόνα· το Alt βάφει με την άλλη λειτουργία Clear Brush Strokes Απαλοιφή πινελιών Show rulers Εμφάνιση χαράκων + Presentation Παρουσίαση # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση @@ -3813,6 +3821,8 @@ curves Smooth Εξομάλυνση @plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop + File Naming Ονομασία αρχείων + Starting serial Αρχικός αριθμός σειράς # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Εισαγωγή διακοπής… Save Set… Αποθήκευση συνόλου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5de43b5b456..37f88f4294d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -555,6 +555,9 @@ Blur Desenfocar Blur Gallery Galería de desenfoque Field Blur… Desenfoque de campo… +# Field Blur pins (#3373) + No pin selected Ningún pin seleccionado + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado. Iris Blur… Desenfoque de iris… Tilt-Shift… Cambio de inclinación… Path Blur… Desenfoque de trazado… @@ -2195,6 +2198,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. No hay ningún trazado para crear una selección: dibuje uno con la herramienta Pluma o seleccione uno en el panel Trazados. Cancel opening Cancelar apertura Cancelled opening {name} Se canceló la apertura de {name} + The document “{name}” does not have an embedded {mode} profile. El documento «{name}» no tiene un perfil {mode} incrustado. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. El documento «{name}» tiene un perfil de color incrustado que no coincide con el espacio de trabajo {mode} actual. + Embedded: {embedded} Incrustado: {embedded} + Working: {working} Espacio de trabajo: {working} Developing {name} Revelando {name} Cancelled {label} Se canceló {label} Esc to cancel Esc para cancelar @@ -2497,6 +2504,7 @@ cameraRaw Amount Cantidad Violet Violeta Reset Restaurar Large document: redrawing on the CPU Documento grande: redibujando en la CPU + Redrawing Redibujando This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo. @@ -2743,6 +2751,7 @@ curves Smooth Suavizar Paint over hair or fur on the image; Alt paints the other mode Pinte sobre el pelo o el pelaje de la imagen; Alt pinta en el otro modo Clear Brush Strokes Borrar trazos de pincel Show rulers Mostrar reglas + Presentation Presentación # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar @@ -2799,6 +2808,8 @@ curves Smooth Suavizar @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos + File Naming Nombres de archivo + Starting serial Número de serie inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insertar detención… Save Set… Guardar conjunto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4925a590702..f7d508fb5c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -543,6 +543,9 @@ Blur Flou Blur Gallery Galerie de flous Field Blur… Flou de champ… +# Field Blur pins (#3373) + No pin selected Aucune épingle sélectionnée + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. Iris Blur… Flou d'iris… Tilt-Shift… Inclinaison-décalage… Path Blur… Flou de tracé… @@ -2155,6 +2158,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Aucun tracé à partir duquel créer une sélection : dessinez-en un avec l'outil Plume ou sélectionnez-en un dans le panneau Tracés. Cancel opening Annuler l'ouverture Cancelled opening {name} Ouverture de {name} annulée + The document “{name}” does not have an embedded {mode} profile. Le document « {name} » n'a pas de profil {mode} incorporé. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Le document « {name} » a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail {mode} actuel. + Embedded: {embedded} Incorporé : {embedded} + Working: {working} Espace de travail : {working} Developing {name} Développement de {name} Cancelled {label} {label} : annulé Esc to cancel Échap pour annuler @@ -2470,7 +2477,6 @@ Choose… Choisir… Select Folder… Sélectionner un dossier… Override Action "Save" Commands Ignorer les commandes « Enregistrer » de l'action - File Naming Dénomination des fichiers Compatibility Compatibilité Mac OS Mac OS Unix Unix @@ -2848,6 +2854,7 @@ cameraRaw Blending Fusion Fuchsia Fuchsia Violet Violet Large document: redrawing on the CPU Document volumineux : redessin sur le CPU + Redrawing Redessin This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur. @@ -3076,6 +3083,7 @@ curves Smooth Lisser Paint over hair or fur on the image; Alt paints the other mode Peignez sur les cheveux ou la fourrure dans l'image ; Alt peint dans l'autre mode Clear Brush Strokes Effacer les traits de pinceau Show rulers Afficher les règles + Presentation Présentation # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer @@ -3117,6 +3125,8 @@ curves Smooth Lisser @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises + File Naming Dénomination des fichiers + Starting serial Numéro de série initial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insérer un arrêt… Save Set… Enregistrer l’ensemble… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ab85d1dfd20..70c22942058 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -561,6 +561,8 @@ Facet Lapolás Fibers… Szálak… Field Blur… Mezőelmosás… + No pin selected Nincs kijelölt tű + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kattints a képre tű hozzáadásához; húzd a tűt az áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. A Delete törli a kijelölt tűt. Fill Path… Útvonal kitöltése… Fill Screen Képernyő kitöltése Filter Szűrő @@ -1715,6 +1717,7 @@ layerLabel Magenta Bíbor Always use pressure for opacity Mindig nyomásfüggő átlátszatlanság Always use pressure for size Mindig nyomásfüggő méret Appearance Mode Megjelenési mód + Presentation Megjelenítés Auto Erase Automatikus radírozás Auto Hide Automatikus elrejtés Auto-Select: Automatikus kijelölés: @@ -2483,6 +2486,10 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva Cancelled opening {name} Megnyitás megszakítva: {name} + The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. + Embedded: {embedded} Beágyazott: {embedded} + Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság Color Detail Színrészletesség @@ -4019,6 +4026,7 @@ cameraRaw Yellow Sárga @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva + Starting serial Kezdő sorszám # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Leállítás beszúrása… Save Set… Készlet mentése… @@ -4049,3 +4057,4 @@ cameraRaw Yellow Sárga Color Indicates: A szín jelzése: Ink Characteristics Festék jellemzői Solidity: Fedőképesség: + Redrawing Újrarajzolás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6e8de38ba7e..dcc02ec1855 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -536,6 +536,9 @@ Blur Blur Blur Gallery Galeri Blur Field Blur… Field Blur… +# Field Blur pins (#3373) + No pin selected Tidak ada pin yang dipilih + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih. Iris Blur… Iris Blur… Tilt-Shift… Tilt-Shift… Path Blur… Path Blur… @@ -2148,6 +2151,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Tidak ada path untuk dibuat seleksi: gambar dengan alat Pen atau pilih salah satu di panel Path. Cancel opening Batalkan pembukaan Cancelled opening {name} Pembukaan {name} dibatalkan + The document “{name}” does not have an embedded {mode} profile. Dokumen “{name}” tidak memiliki profil {mode} tertanam. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumen “{name}” memiliki profil warna tertanam yang tidak cocok dengan working space {mode} saat ini. + Embedded: {embedded} Tertanam: {embedded} + Working: {working} Working space: {working} Developing {name} Mengembangkan {name} Cancelled {label} {label} dibatalkan Esc to cancel Esc untuk batal @@ -2461,7 +2468,6 @@ Choose… Pilih… Select Folder… Pilih Folder… Override Action "Save" Commands Timpa Perintah Action "Save" - File Naming Penamaan Berkas Compatibility Kompatibilitas Mac OS Mac OS Unix Unix @@ -2835,6 +2841,7 @@ cameraRaw Blending Pembauran Fuchsia Fuksia Violet Ungu Large document: redrawing on the CPU Dokumen besar: menggambar ulang di CPU + Redrawing Menggambar ulang This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini. @@ -3063,6 +3070,7 @@ curves Smooth Haluskan Paint over hair or fur on the image; Alt paints the other mode Lukis di atas rambut atau bulu pada gambar; Alt melukis dalam mode lainnya Clear Brush Strokes Hapus Goresan Kuas Show rulers Tampilkan Penggaris + Presentation Presentasi # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus @@ -3104,6 +3112,8 @@ curves Smooth Haluskan @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati + File Naming Penamaan Berkas + Starting serial Nomor urut awal # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Sisipkan Berhenti… Save Set… Simpan Set… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d27912a1b1f..c077d90aa18 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -551,6 +551,9 @@ Blur Sfocatura Blur Gallery Galleria sfocatura Field Blur… Sfocatura campo… +# Field Blur pins (#3373) + No pin selected Nessuna puntina selezionata + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata. Iris Blur… Sfocatura diaframma… Tilt-Shift… Miniatura… Path Blur… Sfocatura tracciato… @@ -2191,6 +2194,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nessun tracciato da cui creare selezione: disegnane uno con Penna o selezionane uno nel pannello Tracciati. Cancel opening Annulla apertura Cancelled opening {name} Apertura {name} annullata + The document “{name}” does not have an embedded {mode} profile. Il documento “{name}” non ha un profilo {mode} incorporato. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Il documento “{name}” ha un profilo colore incorporato che non corrisponde allo spazio di lavoro {mode} corrente. + Embedded: {embedded} Incorporato: {embedded} + Working: {working} Di lavoro: {working} Developing {name} Sviluppo di {name} Cancelled {label} {label} annullato Esc to cancel Esc per annullare @@ -2497,6 +2504,7 @@ cameraRaw Amount Quantità Fuchsia Fucsia Violet Viola Large document: redrawing on the CPU Documento grande: ridisegno sulla CPU + Redrawing Ridisegno This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer. @@ -2731,6 +2739,7 @@ curves Smooth Leviga Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità Clear Brush Strokes Cancella tracce del pennello Show rulers Mostra righelli + Presentation Presentazione # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere @@ -3012,7 +3021,6 @@ curves Smooth Leviga Choose… Scegli… Select Folder… Seleziona cartella… Override Action "Save" Commands Sostituisci i comandi "Salva" dell'azione - File Naming Denominazione file Compatibility Compatibilità Mac OS Mac OS Unix Unix @@ -4042,8 +4050,6 @@ layerLabel No Color Nessun colore Monitor profile on {display}: {profile} Profilo monitor su {display}: {profile} manual manuale fallback ripiego - The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}. - The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working} Importing {what} from {file}… Importazione di {what} da {file}… brushes pennelli gradients sfumature @@ -4080,6 +4086,8 @@ navigator Zoom Zoom @plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop + File Naming Denominazione file + Starting serial Numero di serie iniziale # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserisci interruzione… Save Set… Salva set… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffde2fd5e4c..9f074e3e30b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -553,6 +553,9 @@ Blur ぼかし Blur Gallery ぼかしギャラリー Field Blur… フィールドぼかし… +# Field Blur pins (#3373) + No pin selected ピンが選択されていません + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。 Iris Blur… 虹彩絞りぼかし… Tilt-Shift… チルトシフト… Path Blur… パスぼかし… @@ -2189,6 +2192,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 選択範囲を作成するパスがありません。ペンツールで描くか、パスパネルでパスを選択してください。 Cancel opening 開くのをキャンセル Cancelled opening {name} {name} を開くのをキャンセルしました + The document “{name}” does not have an embedded {mode} profile. ドキュメント「{name}」には埋め込み {mode} プロファイルがありません。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. ドキュメント「{name}」の埋め込みカラープロファイルは、現在の {mode} 作業用スペースと一致しません。 + Embedded: {embedded} 埋め込み: {embedded} + Working: {working} 作業用スペース: {working} Developing {name} {name} を現像中 Cancelled {label} {label} をキャンセルしました Esc to cancel Esc キーでキャンセル @@ -2398,6 +2405,7 @@ cameraRaw Color カラー Violet 紫 Reset 初期化 Large document: redrawing on the CPU 大きなドキュメント: CPU で再描画しています + Redrawing 再描画中 This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 @@ -3305,7 +3313,6 @@ fontWeight Italic 斜体 Exposure Program 露出プログラム Extra Bold エクストラボールド Extra Light エクストラライト - File Naming ファイル名の付け方 Fill Light フィルライト First Color Readout 最初のカラー読み取り Flash フラッシュ @@ -3669,6 +3676,7 @@ curves Smooth スムーズ Paint over hair or fur on the image; Alt paints the other mode 画像の髪や毛の上をペイントします。Alt で反対のモードでペイント Clear Brush Strokes ブラシストロークを消去 Show rulers 定規を表示 + Presentation プレゼンテーション # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除 @@ -3722,6 +3730,8 @@ curves Smooth スムーズ @plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました + File Naming ファイル名の付け方 + Starting serial 開始番号 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… ストップを挿入… Save Set… セットを保存… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 038888f0a96..eefa162a656 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -540,6 +540,9 @@ Blur 흐림 효과 Blur Gallery 흐림 효과 갤러리 Field Blur… 필드 흐림 효과… +# Field Blur pins (#3373) + No pin selected 선택한 핀 없음 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다. Iris Blur… 조리개 흐림 효과… Tilt-Shift… 기울기-이동… Path Blur… 패스 흐림 효과… @@ -2150,6 +2153,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 선택 영역으로 만들 패스가 없습니다: 펜 도구로 그리거나 패스 패널에서 선택하세요. Cancel opening 열기 취소 Cancelled opening {name} {name} 열기를 취소했습니다 + The document “{name}” does not have an embedded {mode} profile. 문서 “{name}”에 포함된 {mode} 프로파일이 없습니다. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 문서 “{name}”에 포함된 색상 프로파일이 현재 {mode} 작업 색상 공간과 일치하지 않습니다. + Embedded: {embedded} 포함됨: {embedded} + Working: {working} 작업 색상 공간: {working} Developing {name} {name} 현상 중 Cancelled {label} {label} 작업을 취소했습니다 Esc to cancel Esc 키로 취소 @@ -3008,6 +3015,7 @@ cameraRaw Color 색상 Indigo 남색 Fuchsia 자홍색 Large document: redrawing on the CPU 큰 문서: CPU에서 다시 그리는 중 + Redrawing 다시 그리는 중 This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다. @@ -3248,6 +3256,7 @@ curves Smooth 매끄럽게 Paint over hair or fur on the image; Alt paints the other mode 이미지의 머리카락이나 털 위를 칠합니다. Alt는 다른 모드로 칠합니다 Clear Brush Strokes 브러시 획 지우기 Show rulers 눈금자 표시 + Presentation 프레젠테이션 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거 @@ -3289,6 +3298,8 @@ curves Smooth 매끄럽게 @plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 + File Naming 파일 이름 지정 + Starting serial 시작 일련번호 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 정지 삽입… Save Set… 세트 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 22adb12a20e..68e98a6fef5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -543,6 +543,9 @@ Blur Vervagen Blur Gallery Vervagingsgalerie Field Blur… Veldvervaging… +# Field Blur pins (#3373) + No pin selected Geen speld geselecteerd + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld. Iris Blur… Irisvervaging… Tilt-Shift… Tilt-shift… Path Blur… Padvervaging… @@ -2157,6 +2160,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Geen pad om een selectie van te maken: teken er een met het pengereedschap of selecteer er een in het paneel Paden. Cancel opening Openen annuleren Cancelled opening {name} Openen van {name} geannuleerd + The document “{name}” does not have an embedded {mode} profile. Het document “{name}” heeft geen ingesloten {mode}-profiel. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Het document “{name}” heeft een ingesloten kleurprofiel dat niet overeenkomt met de huidige {mode}-werkruimte. + Embedded: {embedded} Ingesloten: {embedded} + Working: {working} Werkruimte: {working} Developing {name} {name} wordt ontwikkeld Cancelled {label} {label} geannuleerd Esc to cancel Esc om te annuleren @@ -2427,6 +2434,7 @@ layerLabel No Color Geen kleur Fuchsia Fuchsia Violet Violet Large document: redrawing on the CPU Groot document: opnieuw tekenen op de CPU + Redrawing Opnieuw tekenen This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer. @@ -2852,7 +2860,6 @@ cameraRaw Color Kleur Choose… Kiezen… Select Folder… Map selecteren… Override Action "Save" Commands Opdrachten "Opslaan" in actie negeren - File Naming Bestandsnamen Compatibility Compatibiliteit Include ICC Profile ICC-profiel opnemen Error File Foutbestand @@ -3820,6 +3827,7 @@ curves Smooth Vloeiend maken Include in playback Opnemen in afspelen Scale: Schaal: Snap to Origin Uitlijnen op oorsprong + Presentation Presentatie # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen @@ -3875,6 +3883,8 @@ curves Smooth Vloeiend maken @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten + File Naming Bestandsnamen + Starting serial Beginnummer # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stop invoegen… Save Set… Set opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a366eaba0f7..2d76183794a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -552,6 +552,9 @@ Blur Rozmycie Blur Gallery Galeria rozmyć Field Blur… Rozmycie pola… +# Field Blur pins (#3373) + No pin selected Nie zaznaczono pinezki + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę. Iris Blur… Rozmycie irysowe… Tilt-Shift… Tilt-shift… Path Blur… Rozmycie ścieżki… @@ -2176,6 +2179,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Brak ścieżki, z której można utworzyć zaznaczenie: narysuj ją narzędziem Pióro lub wybierz w panelu Ścieżki. Cancel opening Anuluj otwieranie Cancelled opening {name} Anulowano otwieranie {name} + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}” nie ma osadzonego profilu {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}” ma osadzony profil kolorów, który nie pasuje do bieżącej przestrzeni roboczej {mode}. + Embedded: {embedded} Osadzony: {embedded} + Working: {working} Przestrzeń robocza: {working} Cancelled {label} Anulowano: {label} Esc to cancel Esc, aby anulować Opened {name} Otworzono: {name} @@ -2494,7 +2501,6 @@ Choose… Wybierz… Select Folder… Wybierz folder… Override Action "Save" Commands Zastąp polecenia „Zapisz” w akcji - File Naming Nazewnictwo plików Compatibility Zgodność Mac OS Mac OS Unix Unix @@ -3458,6 +3464,7 @@ cameraRaw Roughness Chropowatość Indigo Indygo Fuchsia Fuksja Large document: redrawing on the CPU Duży dokument: odświeżanie na CPU + Redrawing Odświeżanie This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera. @@ -3672,6 +3679,7 @@ curves Smooth Wygładź Paint over hair or fur on the image; Alt paints the other mode Maluj po włosach lub sierści na obrazie; Alt maluje w drugim trybie Clear Brush Strokes Wyczyść pociągnięcia pędzla Show rulers Pokaż linijki + Presentation Prezentacja # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć @@ -3713,6 +3721,8 @@ curves Smooth Wygładź @plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie + File Naming Nazewnictwo plików + Starting serial Początkowy numer seryjny # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Wstaw zatrzymanie… Save Set… Zapisz zestaw… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9a112170e61..0d48ba9b47b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -537,6 +537,9 @@ Blur Desfoque Blur Gallery Galeria de desfoque Field Blur… Desfoque de campo… +# Field Blur pins (#3373) + No pin selected Nenhum alfinete selecionado + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado. Iris Blur… Desfoque de íris… Tilt-Shift… Tilt-shift… Path Blur… Desfoque de demarcador… @@ -2151,6 +2154,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nenhum demarcador para criar a seleção: desenhe um com a ferramenta Caneta ou selecione um no painel Demarcadores. Cancel opening Cancelar abertura Cancelled opening {name} Abertura de {name} cancelada + The document “{name}” does not have an embedded {mode} profile. O documento “{name}” não tem um perfil {mode} incorporado. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. O documento “{name}” tem um perfil de cores incorporado que não corresponde ao espaço de trabalho {mode} atual. + Embedded: {embedded} Incorporado: {embedded} + Working: {working} Espaço de trabalho: {working} Developing {name} Revelando {name} Cancelled {label} {label} cancelado Esc to cancel Esc para cancelar @@ -2466,7 +2473,6 @@ Choose… Escolher… Select Folder… Selecionar pasta… Override Action "Save" Commands Substituir comandos "Salvar" da ação - File Naming Nomenclatura de arquivos Compatibility Compatibilidade Mac OS Mac OS Unix Unix @@ -2852,6 +2858,7 @@ cameraRaw Whites Brancos Fuchsia Fúcsia Violet Violeta Large document: redrawing on the CPU Documento grande: redesenhando na CPU + Redrawing Redesenhando This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador. @@ -3081,6 +3088,7 @@ curves Smooth Suavizar Paint over hair or fur on the image; Alt paints the other mode Pinte sobre cabelo ou pelo na imagem; Alt pinta no outro modo Clear Brush Strokes Limpar traços do pincel Show rulers Mostrar réguas + Presentation Apresentação # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover @@ -3905,6 +3913,8 @@ layerLabel No Color Sem cor @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas + File Naming Nomenclatura de arquivos + Starting serial Número de série inicial # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserir parada… Save Set… Salvar conjunto… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 697bbd9c5a3..11a3cb6b2e2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -343,6 +343,9 @@ Oil Paint… Масляная живопись… Blur Gallery Галерея размытия Field Blur… Размытие по полю… +# Field Blur pins (#3373) + No pin selected Булавка не выбрана + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку. Iris Blur… Диафрагменное размытие… Tilt-Shift… Наклон-сдвиг… Path Blur… Размытие по контуру… @@ -1403,7 +1406,6 @@ Choose… Выбрать… Select Folder… Выбрать папку… Override Action "Save" Commands Переопределить команды «Сохранить» в операции - File Naming Именование файлов Compatibility Совместимость Windows Windows Mac OS Mac OS @@ -2472,6 +2474,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Нет контура для создания выделения: нарисуйте его инструментом «Перо» или выберите на панели «Контуры». Cancel opening Отменить открытие Cancelled opening {name} Открытие {name} отменено + The document “{name}” does not have an embedded {mode} profile. В документе «{name}» нет встроенного профиля {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Встроенный цветовой профиль документа «{name}» не совпадает с текущим рабочим пространством {mode}. + Embedded: {embedded} Встроенный: {embedded} + Working: {working} Рабочее пространство: {working} Developing {name} Обработка {name} Cancelled {label} {label}: отменено Esc to cancel Esc — отмена @@ -2697,6 +2703,7 @@ cameraRaw Roughness Неровность Fuchsia Фуксия Violet Фиолетовый Large document: redrawing on the CPU Большой документ: перерисовка на CPU + Redrawing Перерисовка This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера. @@ -3005,6 +3012,7 @@ curves Smooth Сгладить Select the crop presets to delete Выберите наборы параметров кадрирования для удаления Enter a name for the crop preset Введите имя набора параметров кадрирования There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения + Presentation Представление # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления @@ -3061,6 +3069,8 @@ curves Smooth Сгладить @plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранены {n} операции в {file}|Сохранено {n} операций в {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущены {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop + File Naming Именование файлов + Starting serial Начальный порядковый номер # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставить остановку… Save Set… Сохранить набор… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 71ea488cc8a..f47fe20162c 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -533,6 +533,8 @@ Blur Zamućenje Blur Gallery Galerija zamućenja Field Blur… Zamućenje polja… + No pin selected Nijedna čioda nije izabrana + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite na sliku da dodate čiodu; prevucite čiodu da je pomerite ili njen prsten da promenite zamućenje. Delete uklanja izabranu čiodu. Iris Blur… Iris zamućenje… Tilt-Shift… Tilt-shift… Path Blur… Zamućenje duž putanje… @@ -2135,6 +2137,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. Cancel opening Otkaži otvaranje Cancelled opening {name} Otkazano otvaranje: {name} + The document “{name}” does not have an embedded {mode} profile. Dokument „{name}“ nema ugrađeni {mode} profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokument „{name}“ ima ugrađeni profil boja koji se ne slaže sa trenutnim {mode} radnim prostorom. + Embedded: {embedded} Ugrađeni: {embedded} + Working: {working} Radni prostor: {working} Developing {name} Razvijanje: {name} Cancelled {label} Otkazano: {label} Esc to cancel Esc za otkazivanje @@ -2892,6 +2898,7 @@ fontWeight Italic Kurziv Auto Hide Notifications Automatski sakrij obaveštenja Notification Duration (seconds) Trajanje obaveštenja (sekunde) Appearance Mode Režim izgleda + Presentation Prikaz Dark Theme Tamna tema Light Theme Svetla tema Pro Medium Pro srednja @@ -4203,3 +4210,4 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + Redrawing Ponovno iscrtavanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2a69c36bbc3..948c9674674 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -532,6 +532,8 @@ Blur Bulanıklaştırma Blur Gallery Bulanıklaştırma Galerisi Field Blur… Alan Bulanıklığı… + No pin selected Seçili pim yok + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Pim eklemek için görüntüye tıklayın; taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Delete seçili pimi siler. Iris Blur… İris Bulanıklığı… Tilt-Shift… Tilt-Shift… Path Blur… Yol Bulanıklığı… @@ -2132,6 +2134,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim yapılabilecek yol yok: Kalem aracıyla bir yol çizin veya Yollar panelinden birini seçin. Cancel opening Açılışı iptal et Cancelled opening {name} {name} açılışı iptal edildi + The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. + Embedded: {embedded} Gömülü: {embedded} + Working: {working} Çalışma alanı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi Esc to cancel İptal etmek için Esc @@ -3637,6 +3643,7 @@ cameraRaw Whites Beyazlar Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) Appearance Mode Görünüm Modu + Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu Sync with system Sistemle senkronizasyon Active Aktif @@ -3830,6 +3837,7 @@ cameraData Lens Lens @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı + Starting serial Başlangıç sıra numarası # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Durdurma Ekle… Save Set… Seti Kaydet… @@ -3860,3 +3868,4 @@ cameraData Lens Lens Color Indicates: Rengin Gösterdiği: Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: + Redrawing Yeniden çiziliyor diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dd75907d143..2a72fdaf19a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -321,6 +321,9 @@ Oil Paint… Олійний живопис… Blur Gallery Галерея розмиття Field Blur… Розмиття поля… +# Field Blur pins (#3373) + No pin selected Шпильку не вибрано + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку. Iris Blur… Розмиття діафрагми… Tilt-Shift… Нахил і зсув… Path Blur… Розмиття вздовж контуру… @@ -1382,7 +1385,6 @@ Choose… Вибрати… Select Folder… Вибрати теку… Override Action "Save" Commands Перевизначити команди «Зберегти» в дії - File Naming Іменування файлів Compatibility Сумісність Windows Windows Mac OS Mac OS @@ -2445,6 +2447,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Немає контуру для створення виділення: намалюйте його пером або виберіть на панелі контурів. Cancel opening Скасувати відкриття Cancelled opening {name} Відкриття {name} скасовано + The document “{name}” does not have an embedded {mode} profile. Документ «{name}» не має вбудованого профілю {mode}. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Вбудований колірний профіль документа «{name}» не відповідає поточному робочому простору {mode}. + Embedded: {embedded} Вбудований: {embedded} + Working: {working} Робочий простір: {working} Developing {name} Обробка {name} Cancelled {label} Скасовано: {label} Esc to cancel Esc для скасування @@ -2667,6 +2673,7 @@ cameraRaw Roughness Шорсткість Fuchsia Фуксія Violet Фіалковий Large document: redrawing on the CPU Великий документ: перемальовування на CPU + Redrawing Перемальовування This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю. Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера. @@ -3758,6 +3765,7 @@ curves Smooth Згладити There is no crop size or ratio to save Немає розміру чи співвідношення сторін кадрування для збереження Scale: Масштаб: Snap to Origin Прив'язати до початку координат + Presentation Представлення # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення @@ -3814,6 +3822,8 @@ curves Smooth Згладити @plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop + File Naming Іменування файлів + Starting serial Початковий порядковий номер # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставити зупинку… Save Set… Зберегти набір… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 035743f3446..88b855a7078 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -508,6 +508,9 @@ Facet Mặt cắt Fibers… Sợi… Field Blur… Làm mờ vùng… +# Field Blur pins (#3373) + No pin selected Chưa chọn ghim nào + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn. Filter Gallery… Thư viện bộ lọc… Find Edges Tìm cạnh Flame… Ngọn lửa… @@ -1562,6 +1565,10 @@ cameraRaw Shadows Vùng tối Esc to cancel Esc để hủy Cancel opening Hủy mở tệp Cancelled opening {name} Đã hủy mở {name} + The document “{name}” does not have an embedded {mode} profile. Tài liệu “{name}” không có hồ sơ {mode} nhúng. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Tài liệu “{name}” có hồ sơ màu nhúng không khớp với không gian làm việc {mode} hiện tại. + Embedded: {embedded} Nhúng: {embedded} + Working: {working} Không gian làm việc: {working} Cancelled {label} Đã hủy {label} Cancel transform (Esc) Hủy biến đổi (Esc) Cancel warp (Esc) Hủy biến dạng (Esc) @@ -2152,6 +2159,7 @@ layers Fill: Độ tô: Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Các lớp không vừa ngân sách bộ nhớ GPU. Tăng mức sử dụng bộ nhớ trong Tùy chọn › Hiệu năng sẽ nâng ngân sách, tối đa một phần tư bộ nhớ máy này. Labels Nhãn Large document: redrawing on the CPU Tài liệu lớn: đang vẽ lại trên CPU + Redrawing Đang vẽ lại Layer deleted, but confirmation preference could not be saved: {error} Đã xóa lớp, nhưng không lưu được tùy chọn xác nhận: {error} Layer From Background… Lớp từ nền… Load a keyboard shortcut set saved as .kys Tải bộ phím tắt đã lưu dạng .kys @@ -3598,6 +3606,8 @@ curves Smooth Làm mượt @plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop + File Naming Đặt tên tệp + Starting serial Số thứ tự bắt đầu # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Chèn điểm dừng… Save Set… Lưu bộ… @@ -3608,6 +3618,7 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Presentation Trình bày Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b8157e06691..28427711244 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -558,6 +558,9 @@ Blur 模糊 Blur Gallery 模糊库 Field Blur… 场景模糊… +# Field Blur pins (#3373) + No pin selected 未选择图钉 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。 Iris Blur… 光圈模糊… Tilt-Shift… 移轴模糊… Path Blur… 路径模糊… @@ -2256,6 +2259,10 @@ cameraRaw Blending 混合 # Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog). Cancel opening 取消打开 Cancelled opening {name} 已取消打开 {name} + The document “{name}” does not have an embedded {mode} profile. 文档“{name}”没有嵌入的 {mode} 配置文件。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文档“{name}”嵌入的颜色配置文件与当前的 {mode} 工作空间不匹配。 + Embedded: {embedded} 嵌入:{embedded} + Working: {working} 工作空间:{working} Developing {name} 正在处理 {name} Cancelled {label} 已取消 {label} Esc to cancel 按 Esc 取消 @@ -2439,6 +2446,7 @@ cameraRaw Blending 混合 Violet 紫罗兰色 Reset 复位 Large document: redrawing on the CPU 大型文档:正在用 CPU 重绘 + Redrawing 正在重绘 This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。 @@ -2683,6 +2691,7 @@ curves Smooth 平滑 Select the crop presets to delete 选择要删除的裁剪预设 Enter a name for the crop preset 输入裁剪预设的名称 There is no crop size or ratio to save 没有可保存的裁剪大小或比例 + Presentation 演示 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除 @@ -2724,6 +2733,8 @@ curves Smooth 平滑 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 + File Naming 文件命名 + Starting serial 起始序号 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 存储组… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e3a6a4b4fae..d02e06646e9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -563,6 +563,9 @@ Blur 模糊 Blur Gallery 模糊收藏館 Field Blur… 區域模糊… +# Field Blur pins (#3373) + No pin selected 未選取圖釘 + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。 Iris Blur… 光圈模糊… Tilt-Shift… 傾斜偏移… Path Blur… 路徑模糊… @@ -2195,6 +2198,10 @@ No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 沒有可用來建立選取範圍的路徑:請使用鋼筆工具繪製,或在路徑面板中選取一個路徑。 Cancel opening 取消開啟 Cancelled opening {name} 已取消開啟 {name} + The document “{name}” does not have an embedded {mode} profile. 文件「{name}」沒有嵌入的 {mode} 描述檔。 + The document “{name}” has an embedded color profile that does not match the current {mode} working space. 文件「{name}」嵌入的色彩描述檔與目前的 {mode} 工作空間不符。 + Embedded: {embedded} 嵌入:{embedded} + Working: {working} 工作空間:{working} Developing {name} 正在處理 {name} Cancelled {label} 已取消 {label} Esc to cancel 按 Esc 取消 @@ -2409,6 +2416,7 @@ cameraRaw Blending 混合 Violet 紫羅蘭色 Reset 重設 Large document: redrawing on the CPU 大型文件:正在以 CPU 重繪 + Redrawing 正在重繪 This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。 Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。 @@ -2653,6 +2661,7 @@ curves Smooth 平滑 Select the crop presets to delete 選取要刪除的裁切預設集 Enter a name for the crop preset 輸入裁切預設集的名稱 There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例 + Presentation 呈現 # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除 @@ -2709,6 +2718,8 @@ curves Smooth 平滑 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 + File Naming 檔案命名 + Starting serial 起始序號 # Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 儲存組合… diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 99479f4e321..29da41d481a 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -527,38 +527,69 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result /// (`{document, warnings, color}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { + let warnings: Vec = + v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let held = |doc, read_only, question, policy_applied| crate::open_prompt::Held { + doc, + read_only, + name: name.to_string(), + path: path.map(str::to_string), + warnings: warnings.clone(), + bytes: None, + slot, + question, + policy_applied, + }; + // Held back for the profile prompt (Ask When Opening). + if let Some(id) = v.get("held").and_then(Value::as_u64) { + let (doc, read_only) = app.session.take_held_open(id).ok_or("the open job's document is gone")?; + crate::open_prompt::arrive(app, held(doc, read_only, v.get("color").cloned(), false)); + return Ok(()); + } let mut index = v.get("document").and_then(Value::as_u64).ok_or("the open job returned no document")? as usize; + // An earlier file is waiting on its prompt: this one opens after it, in order. + if crate::open_prompt::waiting(app) + && let Some(st) = app.session.close(index) + { + let read_only = st.source_read_only; + let doc = Arc::try_unwrap(st.doc).unwrap_or_else(|d| (*d).clone()); + crate::open_prompt::arrive(app, held(doc, read_only, None, true)); + return Ok(()); + } if let Some(slot) = slot && let Ok(moved) = app.run("document.move", json!({"document": index, "to": slot})) { index = moved.get("document").and_then(Value::as_u64).map_or(index, |i| i as usize); } - let warnings: Vec = - v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); app.session.set_active(index); + after_open(app, name, path, &warnings, None); + Ok(()) +} + +/// What every open does once its document is the active one: remember where it came from, say +/// so, show the import warnings (a raw opens in Camera Raw first, developed from `path` or +/// `bytes`) and fire the "Open Document" script events. +pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, warnings: &[String], bytes: Option<&[u8]>) { if let Some(p) = path { app.opened_from(p); } app.sync_views(); - let warnings = crate::file_open::with_missing_fonts(app, warnings); - app.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); + // Fonts the document's type uses that aren't installed join the import warnings (#4559). + let warnings = crate::file_open::with_missing_fonts(app, warnings.to_vec()); + let warnings = warnings.as_slice(); + let opened = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); + app.ui.status = opened.clone(); app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. - if crate::camera_raw_ui::wants_open_dialog(app, &warnings) { - crate::camera_raw_ui::queue_open_dialog(app, name, path, None); - notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &crate::camera_raw_ui::without_develop_note(&warnings)); + if crate::camera_raw_ui::wants_open_dialog(app, warnings) { + crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes); + notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings)); } else { - notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); + notices::io_warnings(app, &opened, warnings); } // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut app.session); app.sync_views(); - let color = v.get("color").cloned().unwrap_or(Value::Null); - let ask = color.get("ask").and_then(Value::as_bool) == Some(true); - if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) { - crate::prefs_ui::open_mismatch(app, &color); - } - Ok(()) } // ------------------------------------------------------------------------------------- drawing diff --git a/crates/ui-egui/src/last_brush.rs b/crates/ui-egui/src/last_brush.rs new file mode 100644 index 00000000000..45c101a7ae3 --- /dev/null +++ b/crates/ui-egui/src/last_brush.rs @@ -0,0 +1,21 @@ +//! The brush the tool was last using, remembered across restarts (`Preferences::last_brush`) so +//! the next launch starts with the same brush, options bar and painting cursor outline. +//! +//! Saved once the pointer is up (a slider drag saves once, like the Brush Preset picker's view), +//! and change-driven: `remember_brush` watches `ToolState::brush_rev`, so an unchanged brush +//! costs a counter compare per frame, not a clone and a JSON serialisation of the whole brush. + +use crate::PhotocraftApp; + +/// Remember the current brush once the pointer is up. +pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !ctx.input(|i| i.pointer.any_down()) { + photocraft_engine::brush_cmds::remember_brush(&mut app.session); + } +} + +/// Restore the remembered brush at launch; stored tips load when the preset store attaches +/// (`Session::attach_preset_store`). +pub fn restore(app: &mut PhotocraftApp) { + photocraft_engine::brush_cmds::restore_brush(&mut app.session); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cd96fe3337..333f6bfecde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,8 +24,10 @@ pub mod analysis_ui; pub mod anchor_tools; pub mod artboard_tool; pub mod artboard_ui; +pub(crate) mod background_redraw; mod blend_keys; pub(crate) mod blend_preview; +pub mod blur_pins; mod brand; pub(crate) mod brush_cursor; pub mod brush_panel; @@ -105,6 +107,7 @@ pub mod icons; pub mod jobs_ui; pub mod kys_import; pub mod lasso_ui; +pub(crate) mod last_brush; pub mod layer_menu_ui; pub mod layer_pick_ui; pub mod layer_picker; @@ -138,6 +141,7 @@ pub mod notices; mod numeric_expression; pub mod object_finder; mod opacity_keys; +pub mod open_prompt; pub mod outline; pub mod paint_mouse; pub mod palette; @@ -632,6 +636,8 @@ pub struct PhotocraftApp { pub(crate) filter_preview: Option, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: filter_preview_worker::Worker, + /// Full CPU canvas redraws running in the background, by document (#4024). + pub(crate) redraws: std::collections::HashMap, /// Select › Color Range dialog preview (proxy document + mask / image textures). pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. @@ -737,6 +743,9 @@ pub struct PhotocraftApp { pub(crate) save_dirs: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, + /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it + /// (see `open_prompt`). + pub(crate) held_opens: std::collections::VecDeque, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -822,6 +831,7 @@ impl PhotocraftApp { filter_preview: None, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: Default::default(), + redraws: Default::default(), color_range: None, adjust_preview: None, synthetic: Vec::new(), @@ -871,6 +881,7 @@ impl PhotocraftApp { tiff_options: None, save_dirs: HashMap::new(), targa_options: None, + held_opens: Default::default(), allow_close: false, stylus: Default::default(), background_jobs: false, @@ -1210,6 +1221,12 @@ impl PhotocraftApp { /// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also /// remembers the path. Brush and gradient files go to the preset libraries instead. pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { + self.open_bytes_from(name, bytes, None) + } + + /// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes + /// to Open Recent), when known. + pub(crate) fn open_bytes_from(&mut self, name: &str, bytes: &[u8], path: Option<&str>) -> Result, String> { if let Some(r) = preset_files_ui::open(self, name, bytes) { return r.map(|()| Vec::new()); } @@ -1217,35 +1234,34 @@ impl PhotocraftApp { // Decoded on a worker: a tab with progress appears now, the document when it's ready // (warnings are shown then). if self.background_jobs { - jobs_ui::start_open(self, name, None, jobs_ui::bytes(bytes))?; + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; - // Edit › Color Settings policies apply on open; mismatches can ask what to do. - // No path yet: a bare name isn't a location to save back to (`open_file` sets the path). - let (_, color) = self.session.open_document(doc, None); - if let Some(st) = self.session.active_mut() { - st.source_read_only = photocraft_io::affinity::is_affinity(bytes); - } - self.sync_views(); - let warnings = file_open::with_missing_fonts(self, warnings); - self.ui.status = crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]); - self.ui.status_error = false; - if camera_raw_ui::wants_open_dialog(self, &warnings) { - camera_raw_ui::queue_open_dialog(self, name, None, Some(bytes)); - notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &camera_raw_ui::without_develop_note(&warnings)); - } else { - notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); - } - // Script events bound to "Open Document". - photocraft_engine::automate_cmds::document_opened(&mut self.session); - self.sync_views(); - let ask = color.get("ask").and_then(Value::as_bool) == Some(true); - if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) { - prefs_ui::open_mismatch(self, &color); - } + // Edit › Color Settings policies apply on open; Ask When Opening holds the file back + // until its prompt is answered, and later files wait behind it (`open_prompt`). + // Missing fonts are read from the document itself: it may not be open yet (#4559). + let warnings = file_open::with_missing_fonts_of(&doc, warnings); + // A developed raw has no embedded profile to ask about. + let developed = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let question = self.session.color.open_question(&doc).filter(|_| !developed); + let wants_raw = camera_raw_ui::wants_open_dialog(self, &warnings); + open_prompt::arrive( + self, + open_prompt::Held { + doc, + read_only: photocraft_io::affinity::is_affinity(bytes), + name: name.to_string(), + path: path.map(str::to_string), + warnings: warnings.clone(), + bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())), + slot: None, + question, + policy_applied: false, + }, + ); Ok(warnings) } @@ -1861,6 +1877,10 @@ impl eframe::App for PhotocraftApp { // remembered once the pointer is up. Here, after every panel, rather than in the // picker's own code: the control channel can set it while the picker is closed. brush_picker::persist(self, &ctx); + // The tool's brush (settings and tip references) is remembered the same way. Like the + // picker's view, in the frame loop, so the control channel can set it with the panels + // closed; the engine's revision counter keeps an unchanged brush free. + last_brush::persist(self, &ctx); // Tool options are remembered between launches the same way (#2814). tool_memory::persist(self, &ctx); // So are View › Extras, Show and Snap To (#3762). @@ -2450,6 +2470,9 @@ mod navigator_gpu_tests; #[cfg(test)] mod color_swatch_tests; +#[cfg(test)] +mod profile_open_tests; + #[cfg(test)] mod profile_prompt_tests; @@ -2500,6 +2523,9 @@ mod new_layer_button_tests; #[cfg(test)] mod smart_filter_rows_tests; +#[cfg(test)] +mod blur_pins_tests; + #[cfg(test)] mod move_outline_tests; diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index c5a04556af2..07c1e5a7b6e 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -574,7 +574,7 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { /// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { - (secondary && mods.alt) || (mods.ctrl && mods.alt && !secondary) + mods.alt && (secondary || mods.ctrl) } /// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs new file mode 100644 index 00000000000..cd04a4fc5fb --- /dev/null +++ b/crates/ui-egui/src/open_prompt.rs @@ -0,0 +1,147 @@ +//! Ask When Opening (Edit › Color Settings): a file whose embedded profile doesn't match the +//! working space (or that has none) is held back while a prompt asks what to do, as Photoshop +//! does: one prompt at a time, naming its file, before that file opens. OK applies the answer to +//! that file and opens it, then asks about the next; Cancel opens neither it nor the files still +//! to come. Files that finish reading while a prompt is up wait their turn, so they open in order. + +use std::sync::Arc; + +use photocraft_doc::{ColorMode, Document}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; + +/// The field that marks a prompt as this module's. +const FIELD: &str = "__heldOpen"; + +/// A file read and decoded, waiting to open. +pub struct Held { + pub doc: Document, + /// The source is read-only (an Affinity file): Save doesn't write back to it. + pub read_only: bool, + pub name: String, + pub path: Option, + pub warnings: Vec, + /// The file's bytes, for Camera Raw to develop from when there is no path. + pub bytes: Option>>, + /// The tab position a reopened file keeps. + pub slot: Option, + /// The profile question ([`photocraft_engine::color_cmds::ColorState::open_question`]), or + /// `None` to open without asking. + pub question: Option, + /// The Color Settings policy was already applied (a background open added it before it had + /// to wait); otherwise opening applies it. + pub policy_applied: bool, +} + +/// Whether a file that just finished reading has to wait: one earlier in the queue does. +pub fn waiting(app: &PhotocraftApp) -> bool { + !app.held_opens.is_empty() +} + +/// Opens `held` now, or queues it behind the file whose prompt is up. +pub fn arrive(app: &mut PhotocraftApp, held: Held) { + app.held_opens.push_back(held); + advance(app); +} + +/// Opens queued files in order until one needs its prompt, which is shown if it isn't yet. +fn advance(app: &mut PhotocraftApp) { + while let Some(front) = app.held_opens.front() { + if front.question.is_some() { + if !app.ui.dialogs.iter().any(|d| d.fields.contains_key(FIELD)) { + show(app); + } + return; + } + if let Some(held) = app.held_opens.pop_front() { + // A failure is shown to the user (`open_failed`); the next file still opens. + let _ = open(app, held, None); + } + } +} + +/// Shows the Embedded Profile Mismatch (or missing profile) prompt for the first queued file. +fn show(app: &mut PhotocraftApp) { + let Some(front) = app.held_opens.front() else { return }; + let Some(q) = &front.question else { return }; + // The mode reads as the catalogs name it (French "RVB"); the file name goes in last, so + // nothing in it is taken for a placeholder. + let mode = tl!(match front.doc.mode { + ColorMode::Cmyk => "CMYK", + ColorMode::Grayscale => "Gray", + _ => "RGB", + }); + let name = front.name.as_str(); + let working = crate::i18n::fmt(tl!("Working: {working}"), &[("working", q.get("working").and_then(Value::as_str).unwrap_or("?"))]); + let missing = q.get("missing").and_then(Value::as_bool) == Some(true); + let message = if missing { + let what = crate::i18n::fmt(tl!("The document “{name}” does not have an embedded {mode} profile."), &[("mode", mode), ("name", name)]); + format!("{what}\n{working}") + } else { + let what = crate::i18n::fmt( + tl!("The document “{name}” has an embedded color profile that does not match the current {mode} working space."), + &[("mode", mode), ("name", name)], + ); + let embedded = crate::i18n::fmt(tl!("Embedded: {embedded}"), &[("embedded", q.get("embedded").and_then(Value::as_str).unwrap_or("?"))]); + format!("{what}\n{embedded}\n{working}") + }; + let action = q.get("action").and_then(Value::as_str).unwrap_or("preserve").to_string(); + crate::prefs_ui::open_mismatch(app, &message, &action, missing, FIELD); +} + +/// Whether `fields` are one of this module's prompts. +pub fn owns(fields: &serde_json::Map) -> bool { + fields.contains_key(FIELD) +} + +/// The prompt's answer: `Some(action)` (OK) opens the first queued file with it and moves on to +/// the next; `None` (Cancel) drops every queued file and stops the reads still running. +pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result { + let Some(action) = action else { + let dropped = app.held_opens.len(); + let name = app.held_opens.front().map(|h| h.name.clone()).unwrap_or_default(); + app.held_opens.clear(); + for job in app.jobs.opens.iter().map(|o| o.job).collect::>() { + app.session.cancel_job(job); + } + app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); + app.ui.status_error = false; + return Ok(json!({"cancelled": dropped})); + }; + let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + let r = open(app, held, Some(action)); + advance(app); + r +} + +/// Opens a queued file, applying `action` (the prompt's answer) or else the Color Settings +/// policy, then does what any open does afterwards. +fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result { + let Held { mut doc, read_only, name, path, warnings, bytes, slot, question, policy_applied } = held; + let index = if question.is_some() || policy_applied { + if let Some(action) = action + && let Err(e) = app.session.color.resolve_open(&mut doc, action) + { + app.open_failed(&name, &e.to_string()); + return Err(e.to_string()); + } + app.session.add_document(doc, None) + } else { + app.session.open_document(doc, None).0 + }; + if let Some(st) = app.session.active_mut() { + st.source_read_only = read_only; + } + let index = match slot { + Some(slot) => app + .run("document.move", json!({"document": index, "to": slot})) + .ok() + .and_then(|m| m.get("document").and_then(Value::as_u64)) + .map_or(index, |i| i as usize), + None => index, + }; + app.session.set_active(index); + crate::jobs_ui::after_open(app, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice)); + Ok(json!({"document": index, "action": action})) +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index c49055e13af..a45c016c596 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -37,6 +37,7 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { // The picked preset was the outgoing tool's brush: forget it, so Update Brush can't overwrite // that preset with this tool's brush. app.session.tools.current_preset = None; + app.session.tools.brush_changed(); app.ui.brush_tool = tool; } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a69b40c41d2..733f923c8ad 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1611,7 +1611,10 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if t.pro { crate::chrome_ui::status_bar_pro(app, ui); // Background job progress with Cancel, at the far end (#210). - ui.with_layout(crate::direction::trailing(), |ui| crate::jobs_ui::status_progress(app, ui)); + ui.with_layout(crate::direction::trailing(), |ui| { + crate::jobs_ui::status_progress(app, ui); + crate::background_redraw::status(app, ui); + }); return; } if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) { @@ -1650,6 +1653,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); crate::jobs_ui::status_progress(app, ui); } + crate::background_redraw::status(app, ui); }); }); }); @@ -1780,7 +1784,7 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { empty(ui, tl!("No document")); return; }; - let (rev, size) = (st.revision, st.doc.size); + let (rev, size, origin) = (st.revision, st.doc.size, st.doc.ruler_origin); // Positions and sizes in the ruler unit (Preferences › Units & Rulers). let units = app.session.prefs().units_and_rulers.clone(); let dpi = st.doc.resolution_dpi as f64; @@ -1829,8 +1833,9 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::hairline(ui); ui.columns(2, |cols| { let dash = || "—".to_string(); - row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64))); - row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64))); + // Measured from the rulers' zero point (#2955). + row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64 - origin[0]))); + row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64 - origin[1]))); row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); }); @@ -2285,7 +2290,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("artboard", "scan", tl!("Prevent auto-nesting in and out of Artboards and Frames"), lk.artboard), ("all", "lock", tl!("Lock all"), lk.all), ] { - if icons::button(ui, icon, 20.0, on, tip).clicked() { + // Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892). + let enabled = key != "transparency" || !lk.pixels; + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() { actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }}))); } } @@ -2478,9 +2485,17 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label))); let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); if new_layer.clicked() { - // A click makes the layer; ⌥-click asks first (the New Layer dialog), as in Photoshop. - let alt = ui.input(|i| i.modifiers.alt); - actions.push(("layer.new.layer".into(), if alt { Value::Null } else { json!({}) })); + // A click makes the layer above the selection; ⌥-click asks first (the New Layer + // dialog) and ⌘/Ctrl-click adds it below, as in Photoshop. + let (alt, below) = ui.input(|i| (i.modifiers.alt, i.modifiers.command)); + let params = if alt { + Value::Null + } else if below { + json!({"position": "below"}) + } else { + json!({}) + }; + actions.push(("layer.new.layer".into(), params)); } actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); @@ -3092,7 +3107,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); } let mut snap_cmd = None; - let mut target: Option = None; + let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; @@ -3103,6 +3118,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { snap_cmd = snapshot_rows(app, ui); for (i, (e, is_redo)) in all.enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, i == current, &e)); if i == current { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { @@ -3125,8 +3141,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); - if resp.clicked() { - target = Some(i as isize - current as isize); + if resp.clicked() && i != current { + target = Some(i); } } }); @@ -3157,13 +3173,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.status = e; app.ui.status_error = true; } - if let Some(delta) = target.filter(|_| app.ui.transform.is_none()) { - let (cmd, n) = if delta < 0 { ("edit.undo", -delta) } else { ("edit.redo", delta) }; - for _ in 0..n { - if app.run(cmd, json!({})).is_err() { - break; - } - } + // One command per click (#2966): stepping with Undo/Redo once per state made the canvas + // recomposite the whole document. + if let Some(state) = target.filter(|_| app.ui.transform.is_none()) + && let Err(e) = app.run("history.selectState", json!({ "state": state })) + { + app.ui.status = e; + app.ui.status_error = true; } } @@ -4622,8 +4638,9 @@ mod snapshot_row_tests { assert_eq!(st.history.entries().len(), steps + 1); assert_eq!(st.history.entries().last().map(String::as_str), Some("Snapshot 1")); assert_eq!(top_pixel(&h), [1.0, 0.0, 0.0, 1.0], "back to red"); - // Right-click › Rename…: type a name, Enter. - h.get_by_label("Snapshot 1").click_secondary(); + // Right-click › Rename…: type a name, Enter. The new "Snapshot 1" state is listed under the + // snapshot rows, so the first match is the snapshot's row. + h.get_all_by_label("Snapshot 1").next().unwrap().click_secondary(); h.run_steps(2); h.get_by_label("Rename…").click(); h.run_steps(2); @@ -4649,8 +4666,8 @@ mod snapshot_row_tests { h.run_steps(2); assert_eq!(snapshot_names(&h), ["Lesson start"], "Escape cancels"); assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps_now + 1, "the click selected it once"); - // Right-click › Delete. - h.get_by_label("Lesson start").click_secondary(); + // Right-click › Delete (the snapshot's row, above the state the click made). + h.get_all_by_label("Lesson start").next().unwrap().click_secondary(); h.run_steps(2); h.get_by_label("Delete").click(); h.run_steps(2); @@ -6035,3 +6052,7 @@ mod properties_target_regressions { assert_eq!(after, before, "pending width belongs to the old document/layer"); } } + +#[cfg(test)] +#[path = "history_panel_select_tests.rs"] +mod history_panel_select_tests; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index f1da8775a96..db908ae23f7 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -220,6 +220,7 @@ pub fn load(app: &mut PhotocraftApp) { } crate::dock::restore(app); crate::brush_picker::restore(app); + crate::last_brush::restore(app); sync_rulers(app); crate::tool_memory::restore(app); crate::view_cmds::restore(app); @@ -993,26 +994,13 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { open_kind(app, "shortcuts", tl!("Keyboard Shortcuts and Menus"), fields) } -/// Open the Embedded Profile Mismatch prompt for the active document. -pub fn open_mismatch(app: &mut PhotocraftApp, report: &Value) -> u64 { - let msg = if report.get("missing").and_then(Value::as_bool) == Some(true) { - let template = "The document does not have an embedded colour profile. Working space: {working}."; - crate::i18n::fmt(tl!(template), &[("working", report["working"].as_str().unwrap_or("?"))]) - } else { - let template = "The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working}"; - crate::i18n::fmt(tl!(template), &[("embedded", report["embedded"].as_str().unwrap_or("?")), ("working", report["working"].as_str().unwrap_or("?"))]) - }; - let action = match report["action"].as_str() { - Some("converted") => "convert", - Some("discarded") => "discard", - _ => "preserve", - }; - open_kind( - app, - "mismatch", - tl!("Embedded Profile Mismatch"), - json!({"message": msg, "action": action, "applied": action, "missing": report.get("missing").is_some()}), - ) +/// Open the Embedded Profile Mismatch prompt for a file waiting to open (`open_prompt`): its +/// `message`, the answer selected first (`action`), and `marker`, the field that routes OK and +/// Cancel back to `open_prompt`. +pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missing: bool, marker: &str) -> u64 { + let mut fields = json!({"message": message, "action": action, "missing": missing}); + fields[marker] = json!(true); + open_kind(app, "mismatch", tl!("Embedded Profile Mismatch"), fields) } // ------------------------------------------------------------------ bodies @@ -1301,133 +1289,170 @@ fn export_field_visible(obj: &Map, key: &str) -> bool { } } -fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { +/// One theme as a Color Theme swatch: its panel colour under a chrome strip with an accent dot, +/// outlined when chosen. The name is the tooltip and the accessible label. A star badge on its +/// corner adds the theme to (or removes it from) the favourites listed by the appearance +/// button's menu; it shows while the swatch is hovered, and stays for a favourite. +fn theme_swatch(ui: &mut egui::Ui, kind: ThemeKind, id: &str, selected: &mut String, favorites: &mut Vec) { + const BADGE: f32 = 7.0; + let t = Tokens::get(ui.ctx()); let p = Tokens::for_kind(kind); - let (rect, _) = ui.allocate_exact_size(vec2(width, 108.0), Sense::hover()); + let label = tl!(&choice_label(id)).to_string(); + let on = selected.as_str() == id; + let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), Sense::click()); let painter = ui.painter(); - painter.rect_filled(rect, 3.0, p.dock); - let bar = egui::Rect::from_min_size(rect.min, vec2(rect.width(), 16.0)); - painter.rect_filled(bar, 0.0, p.chrome); - for (x, w) in [(8.0, 18.0), (31.0, 13.0), (49.0, 16.0), (70.0, 12.0)] { - painter.rect_filled(egui::Rect::from_min_size(bar.min + vec2(x, 6.0), vec2(w, 3.0)), 1.0, p.text_dim); + let inner = rect.shrink(3.0); + painter.rect_filled(inner, 2.0, p.dock); + painter.rect_filled(egui::Rect::from_min_size(inner.min, vec2(inner.width(), 5.0)), 0.0, p.chrome); + painter.circle_filled(inner.right_bottom() - vec2(6.0, 6.0), 2.5, p.accent); + let stroke = if on { + egui::Stroke::new(2.0, t.accent) + } else if resp.hovered() { + egui::Stroke::new(1.0, t.text_dim) + } else { + egui::Stroke::new(1.0, t.card_border) + }; + painter.rect_stroke(rect.shrink(1.0), 3.0, stroke, egui::StrokeKind::Inside); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, &label)); + if resp.on_hover_text(&label).clicked() { + *selected = id.to_string(); } - let tools = egui::Rect::from_min_max(bar.left_bottom(), egui::pos2(rect.left() + 23.0, rect.bottom())); - painter.rect_filled(tools, 0.0, p.chrome); - for y in [24.0, 39.0, 54.0, 69.0, 84.0] { - // The first tool is the active one. - let color = if y == 24.0 { p.accent } else { p.icon }; - painter.rect_filled(egui::Rect::from_min_size(rect.min + vec2(7.0, y), vec2(9.0, 9.0)), 2.0, color); + let Some(theme) = SavedTheme::parse(id) else { return }; + let favorite = favorites.contains(&theme); + let center = rect.right_top() + vec2(-3.0, 3.0); + let badge = egui::Rect::from_center_size(center, vec2(BADGE * 2.0, BADGE * 2.0)); + if !(favorite || ui.rect_contains_pointer(rect.union(badge))) { + return; } - let dock = egui::Rect::from_min_max(egui::pos2(rect.right() - 75.0, bar.bottom()), rect.max); - painter.rect_filled(dock, 0.0, p.dock); - painter.rect_filled(egui::Rect::from_min_size(dock.min, vec2(dock.width(), 12.0)), 0.0, p.tab_strip); - painter.rect_filled(egui::Rect::from_min_size(dock.min + vec2(6.0, 4.0), vec2(31.0, 3.0)), 1.0, p.text_dim); - for y in [36.0, 55.0, 74.0] { - let row = egui::Rect::from_min_size(rect.min + vec2(rect.width() - 71.0, y), vec2(67.0, 16.0)); - // The middle row is the selected layer. - let selected = y == 55.0; - painter.rect_filled(row, 1.0, if selected { p.accent } else { p.card }); - painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(13.0, 10.0)), 1.0, p.canvas); - let label = if selected { egui::Color32::from_white_alpha(210) } else { p.text_dim }; - painter.rect_filled(egui::Rect::from_min_size(row.min + vec2(22.0, 6.0), vec2(35.0, 3.0)), 1.0, label); + ui.painter().circle(center, BADGE, t.card, egui::Stroke::new(1.0, t.card_border)); + let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; + let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }; + let star = egui::Button::image(crate::icons::image(icon, 10.0, tint)).frame(false); + let response = ui.put(badge, star).on_hover_text(action); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action}: {label}"))); + if response.clicked() { + if favorite { + favorites.retain(|saved| *saved != theme); + } else { + favorites.push(theme); + } } - let canvas = egui::Rect::from_min_max(egui::pos2(tools.right(), bar.bottom()), egui::pos2(dock.left(), rect.bottom())); - painter.rect_filled(canvas, 0.0, p.canvas); - let art = canvas.shrink2(vec2(15.0, 13.0)); - painter.rect_filled(art, 0.0, p.card); - painter.rect_stroke(art, 0.0, egui::Stroke::new(1.0, p.card_border), egui::StrokeKind::Inside); } -fn theme_card(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec, options: &[(&str, ThemeKind)], width: f32) { - let Some((_, first_kind)) = options.first() else { return }; +/// A grid row of theme swatches, marked Active when the appearance mode shows this family. +fn theme_row(ui: &mut egui::Ui, title: &str, active: bool, selected: &mut String, favorites: &mut Vec, options: &[(&str, ThemeKind)]) { let t = Tokens::get(ui.ctx()); - egui::Frame::new() - .fill(t.card) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.card_border })) - .corner_radius(t.radius) - .inner_margin(10.0) - .show(ui, |ui| { - ui.set_width(width - 20.0); - // Room for the longer list (six dark themes), so both cards line up. - ui.set_min_height(298.0); - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(title)).strong().color(t.text)); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - ui.add_space(5.0); - let preview = options.iter().find(|(id, _)| *id == selected.as_str()).map_or(*first_kind, |(_, kind)| *kind); - theme_preview(ui, preview, width - 20.0); - ui.add_space(6.0); - for (id, _) in options { - let label = choice_label(id); - let row = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); - let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); - ui.horizontal(|ui| { - ui.radio_value(selected, (*id).to_string(), tl!(&label)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(theme) = SavedTheme::parse(id) { - let favorite = favorites.contains(&theme); - let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; - let star = egui::Button::image(crate::icons::image(icon, 15.0, tint)).frame(false); - let response = ui.add_visible(favorite || hovered, star).on_hover_text(if favorite { - tl!("Remove from favorites") - } else { - tl!("Add to favorites") - }); - response.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - true, - format!("{}: {}", if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }, tl!(&label)), - ) - }); - if response.clicked() { - if favorite { - favorites.retain(|saved| *saved != theme); - } else { - favorites.push(theme); - } - } - } - }); - }); - } - }); -} - -fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Option) { - let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(tl!("Appearance")).strong().color(t.text)); - let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string(); + ui.label(RichText::new(tl!(title)).color(t.text_dim)); ui.horizontal(|ui| { + // Room for the favourite star badges on the swatch corners. + ui.spacing_mut().item_spacing.x = 6.0; + for (id, kind) in options { + theme_swatch(ui, *kind, id, selected, favorites); + } + if active { + ui.add_space(6.0); + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + ui.end_row(); +} + +/// Which of the Appearance rows drawn by `appearance_rows` a search shows. +#[derive(Clone, Copy)] +struct AppearanceRows { + mode: bool, + light: bool, + dark: bool, +} + +impl AppearanceRows { + /// `query` is lowercased; empty shows every row. + fn matching(query: &str) -> Self { + let shown = |key: &str| query.is_empty() || preference_matches("interface", key, query); + Self { mode: shown("appearanceMode"), light: shown("lightTheme"), dark: shown("darkTheme") } + } + + fn any(self) -> bool { + self.mode || self.light || self.dark + } +} + +/// Appearance Mode and the light and dark Color Theme swatches, as rows of the Appearance grid. +fn appearance_rows(ui: &mut egui::Ui, obj: &mut Map, system: Option, rows: AppearanceRows) { + let t = Tokens::get(ui.ctx()); + let mut mode = obj.get("appearanceMode").and_then(Value::as_str).unwrap_or("auto").to_string(); + if rows.mode { ui.label(RichText::new(tl!("Appearance Mode")).color(t.text_dim)); let choices: [(String, &str); 3] = [("auto".into(), tl!("Sync with system")), ("dark".into(), tl!("Dark")), ("light".into(), tl!("Light"))]; let pairs: Vec<(String, &str)> = choices.iter().map(|(id, label)| (id.clone(), *label)).collect(); - crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 190.0); - }); - obj.insert("appearanceMode".into(), json!(mode)); - ui.add_space(8.0); + crate::widgets::dropdown(ui, "appearance-mode", &mut mode, &pairs, 220.0); + ui.end_row(); + } let light_active = mode == "light" || (mode == "auto" && system == Some(egui::Theme::Light)); - let width = ((ui.available_width() - 10.0) / 2.0).max(190.0); + obj.insert("appearanceMode".into(), json!(mode)); let mut light = obj.get("lightTheme").and_then(Value::as_str).unwrap_or("studioLight").to_string(); let mut dark = obj.get("darkTheme").and_then(Value::as_str).unwrap_or("proMedium").to_string(); let mut favorites: Vec = obj.get("favoriteThemes").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - ui.horizontal_top(|ui| { - ui.set_min_width(width * 2.0 + 10.0); - ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| { - theme_card(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS, width); - }); - ui.add_space(10.0); - ui.allocate_ui_with_layout(vec2(width, 300.0), egui::Layout::top_down(egui::Align::Min), |ui| { - theme_card(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS, width); - }); - }); + if rows.light { + theme_row(ui, "Light Theme", light_active, &mut light, &mut favorites, LIGHT_THEME_OPTIONS); + } + if rows.dark { + theme_row(ui, "Dark Theme", !light_active, &mut dark, &mut favorites, DARK_THEME_OPTIONS); + } obj.insert("lightTheme".into(), json!(light)); obj.insert("darkTheme".into(), json!(dark)); obj.insert("favoriteThemes".into(), json!(favorites)); - ui.add_space(12.0); +} + +/// A titled, outlined group of settings (Photoshop's Interface page has Appearance, +/// Presentation and Options). +fn pref_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::new().stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(t.radius).inner_margin(10.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tl!(title)).strong().color(t.text)); + ui.add_space(4.0); + add(ui); + }); + ui.add_space(8.0); +} + +/// Preferences › Interface laid out like Photoshop's: the theme and canvas settings, then UI +/// language, font size and scaling, then everything else. Compact enough that the language +/// shows without scrolling in a small window (#2532). A search (`query`, lowercased) hides the +/// rows that don't match and skips a group left empty. +fn interface_groups(ui: &mut egui::Ui, obj: &mut Map, keys: &[String], lang: crate::i18n::Lang, system: Option, query: &str) { + const APPEARANCE: [&str; 3] = ["canvasColor", "canvasCustomColor", "canvasBorder"]; + const PRESENTATION: [&str; 3] = ["language", "uiFontSize", "uiScale"]; + let shown = |k: &str| keys.iter().any(|key| key == k) && field_visible("interface", obj, k, query); + // In Photoshop's order, not the stored one. + let pick = |names: &[&str]| names.iter().filter(|n| shown(n)).map(|n| n.to_string()).collect::>(); + let appearance = pick(&APPEARANCE); + let presentation = pick(&PRESENTATION); + let rest = keys.iter().filter(|k| !APPEARANCE.contains(&k.as_str()) && !PRESENTATION.contains(&k.as_str()) && shown(k)); + // Checkboxes line up at the left edge, as in Photoshop; the other options keep a label column. + let (checks, other): (Vec, Vec) = rest.cloned().partition(|k| obj.get(k).is_some_and(Value::is_boolean)); + let rows = AppearanceRows::matching(query); + let grid = |id: &str| egui::Grid::new(("prefs-grid", id.to_string())).num_columns(2).spacing([14.0, 7.0]).min_col_width(150.0); + if rows.any() || !appearance.is_empty() { + pref_group(ui, "Appearance", |ui| { + grid("interface-appearance").show(ui, |ui| { + appearance_rows(ui, obj, system, rows); + field_rows(ui, "interface", obj, appearance, lang); + }); + }); + } + if !presentation.is_empty() { + pref_group(ui, "Presentation", |ui| { + grid("interface-presentation").show(ui, |ui| field_rows(ui, "interface", obj, presentation, lang)); + }); + } + if !checks.is_empty() || !other.is_empty() { + pref_group(ui, "Options", |ui| { + egui::Grid::new("prefs-grid-interface-checks").num_columns(2).spacing([0.0, 7.0]).show(ui, |ui| field_rows(ui, "interface", obj, checks, lang)); + grid("interface-options").show(ui, |ui| field_rows(ui, "interface", obj, other, lang)); + }); + } } /// Where a clicked colour swatch leaves its preference path and label for the dialog body, which @@ -1439,6 +1464,22 @@ fn ctx_take_pick(ui: &egui::Ui) -> Option<(String, String)> { ui.ctx().data_mut(|d| d.remove_temp::<(String, String)>(egui::Id::new(PICK_ID))) } +/// Whether `section.key` gets a generic row for this search (`query`, lowercased; empty matches +/// everything). Settings nothing reads yet stay out of the dialog (issue #204), as do those drawn +/// by their own widgets; their stored values pass through untouched. +fn field_visible(section: &str, obj: &Map, k: &str, query: &str) -> bool { + let path = format!("{section}.{k}"); + let drawn_elsewhere = (section == "interface" && matches!(k, "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) + // View › Rulers (⌘R) sets it, as in Photoshop. + || path == "unitsAndRulers.showRulers" + || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + || (section == "export" && !export_field_visible(obj, k)); + (query.is_empty() || preference_matches(section, k, query)) + && !drawn_elsewhere + && !prefs::is_hidden(&path) + && obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_))) +} + /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, /// number fields with the preference's range, text fields. fn section_fields( @@ -1450,138 +1491,128 @@ fn section_fields( system: Option, search: &str, ) { - let t = Tokens::get(ui.ctx()); let query = search.to_lowercase(); - if section == "interface" && (query.is_empty() || ["appearanceMode", "darkTheme", "lightTheme"].iter().any(|key| preference_matches(section, key, &query))) - { - appearance_rows(ui, obj, system); + let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); + keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned()); + if section == "interface" { + interface_groups(ui, obj, &keys, lang, system, &query); + return; } if section == "performance" && (query.is_empty() || preference_matches(section, "renderingMode", &query)) { rendering_mode_row(ui, obj); } - let mut keys: Vec = order.iter().filter(|k| obj.contains_key(*k)).cloned().collect(); - keys.extend(obj.keys().filter(|k| !order.contains(k)).cloned()); - egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| { - for k in keys { - let path = format!("{section}.{k}"); - if !query.is_empty() && !preference_matches(section, &k, &query) { - continue; - } - // Settings nothing reads yet stay out of the dialog (issue #204); their stored values - // pass through untouched. - if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes")) - // View › Rulers (⌘R) sets it, as in Photoshop. - || path == "unitsAndRulers.showRulers" - || prefs::is_hidden(&path) - || (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode")) - || (section == "export" && !export_field_visible(obj, &k)) - { - continue; - } - let v = obj.get(&k).cloned().unwrap_or(Value::Null); - let human = humanize(&k); - let label = tl!(&human).to_string(); - match &v { - Value::Bool(b) => { - ui.label(""); - let mut b = *b; - if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - ui.vertical(|ui| { - crate::widgets::checkbox(ui, &mut b, &label); - ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); - }); - } else { - crate::widgets::checkbox(ui, &mut b, &label); - } - obj.insert(k, json!(b)); - } - Value::String(s) if path == "interface.language" => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))]; - pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name()))); - let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); - obj.insert(k, json!(cur)); - } - Value::String(s) if prefs::choices(&path).is_some() => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let opts = prefs::choices(&path).unwrap_or(&[]); - let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); - let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); - let mut cur = s.clone(); - crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); - obj.insert(k, json!(cur)); - } - Value::String(s) if prefs::is_color(&path) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]); - let mut rgb = c; - ui.horizontal(|ui| { - // PhotoCraft's Color Picker, as everywhere else (#2144). - if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone()))); - } - hex_field(ui, &path, &mut rgb); - }); - obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); - let _ = color_of(s); - } - Value::String(s) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut s = s.clone(); - ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); - obj.insert(k, json!(s)); - } - Value::Number(n) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9)); - if n.is_u64() || n.is_i64() { - let mut x = n.as_i64().unwrap_or(0); - ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num)); - obj.insert(k, json!(x)); - } else { - let mut x = n.as_f64().unwrap_or(0.0); - ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num)); - obj.insert(k, json!(x)); - } - } - Value::Array(_) if path == "tools.pressureCurve" => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) { - obj.insert(k, next); - } - } - Value::Array(items) if k == "disks" => { - ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim)); - let mut items = items.clone(); + keys.retain(|k| field_visible(section, obj, k, &query)); + egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang)); +} + +/// One grid row per setting in `keys` (already filtered by `field_visible`). +fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, keys: Vec, lang: crate::i18n::Lang) { + let t = Tokens::get(ui.ctx()); + for k in keys { + let path = format!("{section}.{k}"); + let v = obj.get(&k).cloned().unwrap_or(Value::Null); + let human = humanize(&k); + let label = tl!(&human).to_string(); + match &v { + Value::Bool(b) => { + ui.label(""); + let mut b = *b; + if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { ui.vertical(|ui| { - for d in &mut items { - ui.horizontal(|ui| { - let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false); - let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string(); - crate::widgets::checkbox(ui, &mut on, ""); - ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0)); - *d = json!({"enabled": on, "path": path}); - }); - } - if ui.small_button(tl!("Add disk")).clicked() { - items.push(json!({"enabled": true, "path": ""})); - } + crate::widgets::checkbox(ui, &mut b, &label); + ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); }); - obj.insert(k, Value::Array(items)); + } else { + crate::widgets::checkbox(ui, &mut b, &label); } - Value::Array(items) => { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint)); - if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() { - obj.insert(k, json!([])); - } - } - _ => continue, + obj.insert(k, json!(b)); } - ui.end_row(); + Value::String(s) if path == "interface.language" => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))]; + pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name()))); + let mut cur = s.clone(); + crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); + obj.insert(k, json!(cur)); + } + Value::String(s) if prefs::choices(&path).is_some() => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let opts = prefs::choices(&path).unwrap_or(&[]); + let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); + let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); + let mut cur = s.clone(); + crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); + obj.insert(k, json!(cur)); + } + Value::String(s) if prefs::is_color(&path) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]); + let mut rgb = c; + ui.horizontal(|ui| { + // PhotoCraft's Color Picker, as everywhere else (#2144). + if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), &label).clicked() { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PICK_ID), (path.clone(), label.clone()))); + } + hex_field(ui, &path, &mut rgb); + }); + obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + let _ = color_of(s); + } + Value::String(s) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let mut s = s.clone(); + ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + obj.insert(k, json!(s)); + } + Value::Number(n) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + let (lo, hi) = prefs::range(&path).unwrap_or((-1e9, 1e9)); + if n.is_u64() || n.is_i64() { + let mut x = n.as_i64().unwrap_or(0); + ui.add(egui::DragValue::new(&mut x).range(lo as i64..=hi as i64).custom_parser(crate::widgets::parse_num)); + obj.insert(k, json!(x)); + } else { + let mut x = n.as_f64().unwrap_or(0.0); + ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3).custom_parser(crate::widgets::parse_num)); + obj.insert(k, json!(x)); + } + } + Value::Array(_) if path == "tools.pressureCurve" => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + if let Some(next) = crate::pressure_curve_ui::editor(ui, &v) { + obj.insert(k, next); + } + } + Value::Array(items) if k == "disks" => { + ui.label(RichText::new(tl!("Scratch disks")).color(t.text_dim)); + let mut items = items.clone(); + ui.vertical(|ui| { + for d in &mut items { + ui.horizontal(|ui| { + let mut on = d.get("enabled").and_then(Value::as_bool).unwrap_or(false); + let mut path = d.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + crate::widgets::checkbox(ui, &mut on, ""); + ui.add(egui::TextEdit::singleline(&mut path).desired_width(240.0)); + *d = json!({"enabled": on, "path": path}); + }); + } + if ui.small_button(tl!("Add disk")).clicked() { + items.push(json!({"enabled": true, "path": ""})); + } + }); + obj.insert(k, Value::Array(items)); + } + Value::Array(items) => { + ui.label(RichText::new(tl!(&label)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::trn(lang, items.len() as u64, "{n} item", "{n} items")).color(t.text_faint)); + if !items.is_empty() && ui.small_button(tl!("Clear")).clicked() { + obj.insert(k, json!([])); + } + } + _ => continue, } - }); + ui.end_row(); + } } /// Keyboard Shortcuts and Menus: a searchable list of commands by menu with editable shortcuts @@ -1967,13 +1998,10 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let action = f.get("action").and_then(Value::as_str).unwrap_or("preserve"); - let applied = f.get("applied").and_then(Value::as_str).unwrap_or("preserve"); - if action == applied { - return Ok(json!({"action": action})); - } - app.run("color.profileMismatch", json!({"action": action})) + crate::open_prompt::answer(app, Some(action)) } _ => Err("unknown dialog".into()), } @@ -2349,6 +2377,67 @@ mod tests { assert_eq!(dark, vec![ThemeKind::Studio]); } + #[test] + fn preferences_interface_shows_language_without_scrolling() { + // #2532: in a small window the theme cards filled the page and Language sat out of view + // below them. Like Photoshop, Presentation follows Appearance and both fit. + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let language = h.get_by_label("Language").rect(); + let dark = h.get_by_label("Dark Theme").rect(); + let ok = h.get_by_label("OK").rect(); + assert!(dark.bottom() < language.top(), "Presentation {language:?} follows the theme swatches {dark:?}"); + assert!(language.bottom() < ok.top() - 20.0, "Language {language:?} must show above the buttons {ok:?} without scrolling"); + // Every theme in the option lists gets a swatch, Breeze included. + for (id, _) in LIGHT_THEME_OPTIONS.iter().chain(DARK_THEME_OPTIONS) { + let label = choice_label(id); + assert!(h.query_by_label(&label).is_some(), "missing theme swatch {label}"); + } + assert!(h.query_by_label("Breeze light").is_some() && h.query_by_label("Breeze dark").is_some()); + } + + #[test] + fn preferences_interface_search_hides_rows_and_skips_empty_groups() { + use egui_kittest::{Harness, kittest::Queryable}; + let render = |search: &str| { + let (mut app, _) = app_with_store(); + app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + let id = open_preferences(&mut app, "interface"); + app.ui.dialog_mut(id).unwrap().fields.insert("__search".into(), json!(search)); + let mut h = Harness::builder().with_size(vec2(1000.0, 750.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + h + }; + let shown = |h: &Harness<'_, PhotocraftApp>, label: &str| h.query_all_by_label(label).next().is_some(); + + let h = render("language"); + assert!(shown(&h, "Language") && shown(&h, "Presentation")); + for gone in ["Appearance", "Appearance Mode", "Dark Theme", "Options", "UI scale"] { + assert!(!shown(&h, gone), "\"language\" must not show {gone}"); + } + + let h = render("theme"); + assert!(shown(&h, "Appearance") && shown(&h, "Light Theme") && shown(&h, "Dark Theme") && shown(&h, "Breeze dark")); + for gone in ["Appearance Mode", "Presentation", "Language", "Options"] { + assert!(!shown(&h, gone), "\"theme\" must not show {gone}"); + } + + let h = render(""); + for group in ["Appearance", "Appearance Mode", "Presentation", "Options", "UI scale"] { + assert!(shown(&h, group), "an empty search shows {group}"); + } + } + /// Every generated preference label, section title and choice label has an entry in each /// language that claims complete menus (Japanese, Traditional Chinese, ...). #[test] diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs new file mode 100644 index 00000000000..6828da6f70b --- /dev/null +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -0,0 +1,255 @@ +//! Files opened together whose embedded profile doesn't match the working space (File › Open with +//! several selected, Ask When Opening on). Checked in Photoshop 27.11: one Embedded Profile +//! Mismatch prompt at a time, naming its file, before that file opens; OK opens it and asks about +//! the next; Cancel opens neither it nor the files still to come. + +use crate::{PhotocraftApp, Services}; +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; +use photocraft_engine::Session; +use serde_json::Value; + +const USE: &str = "Use the embedded profile (instead of the working space)"; +const CONVERT: &str = "Convert document's colors to the working space"; +const DISCARD: &str = "Discard the embedded profile (don't color manage)"; + +/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. +fn adobe_png() -> Vec { + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0)); + d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); + photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes +} + +fn harness(background: bool) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new( + Session::new(), + Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + ..Default::default() + }, + ) + }); + h.state_mut().background_jobs = background; + h.run_steps(3); + h.state_mut().run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + h +} + +/// Open a.png, b.png and c.png together and let every read finish. +fn open_three(h: &mut Harness<'static, PhotocraftApp>) { + let png = adobe_png(); + for name in ["a.png", "b.png", "c.png"] { + h.state_mut().open_file(&format!("/pics/{name}"), &png).unwrap(); + } + settle(h); +} + +/// Run frames until no open is still reading. +fn settle(h: &mut Harness<'static, PhotocraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if h.state().jobs.opens.is_empty() && h.state().session.jobs().is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(6); +} + +fn docs(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.documents().iter().map(|d| d.doc.name.clone()).collect() +} + +/// The one prompt up: its message. +fn prompt(h: &Harness<'static, PhotocraftApp>) -> String { + let d = &h.state().ui.dialogs; + assert_eq!(d.len(), 1, "one prompt at a time"); + d[0].fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// A real click (pointer events, hit-tested like a mouse). +fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + let at: Pos2 = h.query_all_by_label(label).find(|n| !n.accesskit_node().is_disabled()).map(|n| n.rect().center()).unwrap(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + settle(h); +} + +fn answer(h: &mut Harness<'static, PhotocraftApp>, choice: &str) { + click(h, choice); + click(h, "OK"); +} + +fn profile(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option { + let st = h.state().session.documents().iter().find(|d| d.doc.name == name).unwrap(); + st.doc.icc_profile.as_ref().map(|b| photocraft_engine::color_cmds::profile_from_bytes(b).unwrap().description.clone()) +} + +/// The file the prompt on screen asks about. +fn asked(h: &Harness<'static, PhotocraftApp>) -> &'static str { + let msg = prompt(h); + ["a.png", "b.png", "c.png", "d.png"] + .into_iter() + .find(|n| msg.starts_with(&format!("The document “{n}” has an embedded color profile that does not match the current RGB working space."))) + .unwrap_or_else(|| panic!("the prompt names its file: {msg}")) +} + +/// Files read on the spot ask in the order they were given. Read in the background, they ask in +/// the order their reads finish, which is the same order unless a later read overtakes an earlier +/// one: the tests hold either way. +fn in_order(background: bool, order: &[&str]) { + if !background { + assert_eq!(order, &["a.png", "b.png", "c.png"][..order.len()]); + } +} + +#[test] +fn files_opened_together_ask_one_at_a_time_before_each_opens() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + let mut answered: Vec<&str> = Vec::new(); + for _ in 0..3 { + assert_eq!(docs(&h), answered, "background {background}: a file opens only after its prompt"); + let name = asked(&h); + assert!(!answered.contains(&name), "background {background}: {name} asked twice"); + answer(&mut h, USE); + answered.push(name); + } + in_order(background, &answered); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(docs(&h), answered, "background {background}: each opened when it was answered"); + assert_eq!(h.state().session.active().map(|d| d.doc.name.as_str()), answered.last().copied()); + } +} + +/// The prompt reads in the interface language: its sentence, the Embedded and Working lines and +/// the mode's name (French "RVB"). The file's name goes in as it is, braces and all. +#[test] +fn the_prompt_is_translated_and_still_names_its_file() { + let png = adobe_png(); + for (code, sentence, embedded, working) in [ + ( + "en", + "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", + "Embedded: Adobe RGB", + "Working: sRGB", + ), + ( + "de", + "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", + "Eingebettet: Adobe RGB", + "Arbeitsfarbraum: sRGB", + ), + ( + "fr", + "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", + "IncorporéNBSP: Adobe RGB", + "Espace de travailNBSP: sRGB", + ), + ( + "ja", + "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", + "埋め込み: Adobe RGB", + "作業用スペース: sRGB", + ), + ] { + let services = Services { + import: Some(Box::new(|name, bytes, depth| { + photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + let lang = crate::i18n::Lang::from_code(code).unwrap(); + crate::i18n::with_language(lang, || app.open_file("/pics/shot {mode}.png", &png)).unwrap(); + assert!(app.session.documents().is_empty(), "{code}: held until answered"); + let message = app.ui.dialogs[0].fields.get("message").and_then(Value::as_str).unwrap().to_string(); + let lines: Vec<&str> = message.lines().collect(); + assert_eq!(lines.len(), 3, "{code}: {message}"); + assert_eq!(lines[0], sentence.replace("NBSP", "\u{a0}"), "{code}"); + assert!(lines[1].starts_with(&embedded.replace("NBSP", "\u{a0}")), "{code}: {}", lines[1]); + assert!(lines[2].starts_with(&working.replace("NBSP", "\u{a0}")), "{code}: {}", lines[2]); + } +} + +/// #4559 with a held file: type in a font that isn't installed is reported with the import +/// warnings while the file still waits for its profile prompt, and shown once when it opens. +#[test] +fn a_held_file_still_reports_its_missing_fonts() { + const MISSING: &str = "NoSuchFamily3471"; + let warning = format!("font '{MISSING}' is not installed; text may render using a fallback face"); + let mut s = Session::new(); + s.execute("file.new", serde_json::json!({"width": 40, "height": 20})).unwrap(); + s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); + let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: usize| Ok((doc.clone(), Vec::new())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); + assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]); + assert!(app.session.documents().is_empty() && app.ui.notices.is_empty(), "held: nothing shown yet"); + crate::open_prompt::answer(&mut app, Some("preserve")).unwrap(); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.notices.len(), 1); + assert_eq!(app.ui.notices[0].lines, vec![warning]); +} + +#[test] +fn each_answer_applies_to_its_own_file() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + // Whichever file a prompt names gets that file's own answer. + let mut answered: Vec<&str> = Vec::new(); + for _ in 0..3 { + let name = asked(&h); + answer( + &mut h, + match name { + "a.png" => CONVERT, + "b.png" => DISCARD, + _ => USE, + }, + ); + answered.push(name); + } + in_order(background, &answered); + assert_eq!(docs(&h), answered); + assert_eq!(profile(&h, "a.png").as_deref(), Some("sRGB IEC61966-2.1"), "background {background}: a converted"); + assert_eq!(profile(&h, "b.png"), None, "background {background}: b discarded"); + assert!(profile(&h, "c.png").unwrap().starts_with("Adobe RGB"), "background {background}: c kept"); + } +} + +#[test] +fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { + for background in [false, true] { + let mut h = harness(background); + open_three(&mut h); + let first = asked(&h); + in_order(background, &[first]); + answer(&mut h, USE); + click(&mut h, "Cancel"); + settle(&mut h); + assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt left"); + assert_eq!(docs(&h), [first], "background {background}"); + // A later open asks again. + h.state_mut().open_file("/pics/d.png", &adobe_png()).unwrap(); + settle(&mut h); + assert_eq!(asked(&h), "d.png"); + } +} diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index ca206b6a528..cec2a8c289d 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -1,6 +1,6 @@ -//! Embedded Profile Mismatch prompts when several files open at once (File › Open with several -//! selected): every prompt can be answered with a real click, one after another, even when the -//! user clicks while the next file's prompt is appearing. +//! Stacked dialogs of the same size in the same place (Embedded Profile Mismatch prompts used to +//! stack like this when several files opened at once): each can be answered with a real click, +//! one after another, even when an older one has been raised above the live one. use crate::{PhotocraftApp, Services}; use egui::{Modifiers, PointerButton, Pos2, vec2}; @@ -8,16 +8,8 @@ use egui_kittest::{ Harness, kittest::{NodeT, Queryable}, }; -use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; -/// A PNG tagged with Adobe RGB, which doesn't match the sRGB working space. -fn adobe_png() -> Vec { - let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.2, 0.6, 0.9, 1.0)); - d.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); - photocraft_io::export(&d, "t.png", &Default::default()).unwrap().bytes -} - fn harness(background: bool) -> Harness<'static, PhotocraftApp> { let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); @@ -36,11 +28,13 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { h } -/// Open `n` mismatched files in one go and wait until their prompts are up and laid out. +/// Stack `n` identical Embedded Profile Mismatch dialogs (same size, same place; only the newest +/// is live) and wait until they are up and laid out. Opening files no longer stacks them (they ask +/// one at a time, `profile_open_tests`), so they are opened directly. fn open_together(h: &mut Harness<'static, PhotocraftApp>, n: usize) { - let png = adobe_png(); - for i in 0..n { - h.state_mut().open_file(&format!("shot{i}.png"), &png).unwrap(); + let message = "The document “shot.png” has an embedded color profile that does not match the current RGB working space."; + for _ in 0..n { + crate::prefs_ui::open_mismatch(h.state_mut(), message, "preserve", false, "__stacked"); } for _ in 0..100 { h.run_steps(2); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index ecc88a02534..3e354077814 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -29,6 +29,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample}); if app.ui.tool == Tool::CloneStamp { + p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); } @@ -709,6 +710,30 @@ mod tests { } } + #[test] + fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { + for pattern_preview in [false, true] { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[10, 10]], "size": 24, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([10.0, 10.0]); + app.ui.view.pattern_preview = pattern_preview; + let modifiers = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 98.0, y: 30.0, pressure: 1.0 }, modifiers); + tool_event(&mut app, ToolEvent::Up { x: 98.0, y: 30.0 }, modifiers); + assert!(!app.ui.status_error, "{}", app.ui.status); + let surface = active(&app).surface().unwrap(); + let center = surface.rgba(98, 30); + assert!(center[0] > 0.9 && center[1] < 0.1, "clone center: {center:?}"); + let opposite = surface.rgba(1, 30); + if pattern_preview { + assert!(opposite[0] > 0.9 && opposite[1] < 0.1, "Pattern Preview must wrap the clone footprint: {opposite:?}"); + } else { + assert!(opposite[0] > 0.9 && opposite[1] > 0.9, "ordinary cloning must not wrap: {opposite:?}"); + } + } + } + #[test] fn alt_flips_dodge_burn_and_blur_sharpen() { for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] { diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index fa22ac98626..5e173da5dc8 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -322,10 +322,7 @@ mod tests { let ctx = egui::Context::default(); let mut enabled = None; let mut disabled = None; - let raw = egui::RawInput { - events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], - ..Default::default() - }; + let raw = egui::RawInput { events: vec![Event::Rotate(std::f32::consts::FRAC_PI_6)], ..Default::default() }; let mut output = ctx.run_ui(raw, |ui| { enabled = trackpad_delta(ui.ctx(), true); disabled = trackpad_delta(ui.ctx(), false); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index cc427758feb..ff5f106bde0 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -237,34 +237,59 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { /// the document origin as the rotated view draws it, as in Photoshop. Tilted guides then meet the /// rulers near their numbers. Measuring along the rotated axes instead squeezed the ticks into a /// few pixels (or none at 90°) and left the rest of the ruler empty (#3233). -fn ruler_xform(xf: &ViewXform) -> ViewXform { +/// +/// The returned mapping measures from the rulers' zero point `zero` (document px, #2955): its +/// document coordinates are distances from `zero`, and 0 sits where the canvas draws `zero`. +fn ruler_xform(xf: &ViewXform, zero: [f64; 2]) -> ViewXform { let level = ViewXform { rotation: 0.0, ..*xf }; - // The level view is an axis-aligned scale, so moving the origin by `shift` on screen moves - // every point by it: re-centre on the document point that sat `shift` before the centre. - let shift = xf.to_screen(0.0, 0.0) - level.to_screen(0.0, 0.0); + let (zx, zy) = (zero[0] as f32, zero[1] as f32); + // The level view is an axis-aligned scale, so moving the zero point by `shift` on screen moves + // every point by it: re-centre on the document point that sat `shift` before the centre, then + // count from the zero point. + let shift = xf.to_screen(zx, zy) - level.to_screen(zx, zy); let center = level.to_doc(xf.rect.center() - shift); + let center = [center[0] as f32 - zx, center[1] as f32 - zy]; if !(center[0].is_finite() && center[1].is_finite()) { return level; } - ViewXform { center: [center[0] as f32, center[1] as f32], ..level } + ViewXform { center, ..level } } /// Visible ruler range in ruler units, normalised to `(min, max)` so the tick loop runs for -/// flipped views too (#1814). `xf` is the canvas mapping; rotation is ignored (see -/// [`ruler_xform`]). Shared by the renderer and its tests. -fn ruler_range(xf: &ViewXform, a: Pos2, b: Pos2, axis: usize, px_per_unit: f64) -> (f64, f64) { - let rxf = ruler_xform(xf); +/// flipped views too (#1814), measured from the zero point `zero`. `xf` is the canvas mapping; +/// rotation is ignored (see [`ruler_xform`]). Shared by the renderer and its tests. +fn ruler_range(xf: &ViewXform, a: Pos2, b: Pos2, axis: usize, px_per_unit: f64, zero: [f64; 2]) -> (f64, f64) { + let rxf = ruler_xform(xf, zero); let d0 = rxf.to_doc(a)[axis]; let d1 = rxf.to_doc(b)[axis]; let (u0, u1) = (d0 / px_per_unit, d1 / px_per_unit); (u0.min(u1), u0.max(u1)) } +/// Snap a document position to the nearest ruler tick, measured from the zero point `origin` +/// (`tick` px apart). Shift-dragging the zero point lands it on a tick, as in Photoshop. +fn snap_px_to_tick(px: f64, origin: f64, tick: f64) -> f64 { + if !(tick.is_finite() && tick > 0.0) { + return px; + } + origin + ((px - origin) / tick).round() * tick +} + +/// Where a zero-point drag from the ruler corner lands for the pointer at document `d`: on the +/// nearest ruler tick (`ticks`, px per axis) with Shift, else View › Snap To targets. +fn origin_drop(app: &mut PhotocraftApp, d: [f64; 2], shift: bool, origin: [f64; 2], ticks: [f64; 2]) -> [f64; 2] { + if shift { + [snap_px_to_tick(d[0], origin[0], ticks[0]), snap_px_to_tick(d[1], origin[1], ticks[1])] + } else { + [crate::snap_ui::snap_guide(app, true, d[0]), crate::snap_ui::snap_guide(app, false, d[1])] + } +} + /// Screen coordinate along the ruler (x for the top ruler, y for the left one) of the value `u` -/// in ruler units. The inverse of [`ruler_range`]'s mapping. -fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64) -> f32 { +/// in ruler units from the zero point `zero`. The inverse of [`ruler_range`]'s mapping. +fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64, zero: [f64; 2]) -> f32 { let px = (u * px_per_unit) as f32; - let rxf = ruler_xform(xf); + let rxf = ruler_xform(xf, zero); if vertical { rxf.to_screen(0.0, px).y } else { rxf.to_screen(px, 0.0).x } } @@ -282,13 +307,14 @@ fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) (px_per_unit, nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), unit == Unit::Pixels)) } -/// `pos` (document px) moved to the nearest tick mark the ruler draws: where a guide lands when +/// `pos` (document px) moved to the nearest tick mark the ruler draws (counted from the zero point): where a guide lands when /// Shift is held while dragging it out of a ruler, as in Photoshop (#3770). fn snap_to_tick(app: &PhotocraftApp, xf: &ViewXform, vertical: bool, pos: f64) -> f64 { // A vertical guide sits at an x position, which the top ruler measures. let (px_per_unit, step) = ruler_scale(app, xf, !vertical); - let tick = step / 10.0 * px_per_unit; - if tick.is_finite() && tick > 0.0 { (pos / tick).round() * tick } else { pos } + // Ticks count from the zero point (#2955), which a vertical guide measures along x. + let origin = app.session.active().map_or(0.0, |d| d.doc.ruler_origin[usize::from(!vertical)]); + snap_px_to_tick(pos, origin, step / 10.0 * px_per_unit) } /// The number a ruler prints at the tick `v` (in ruler units). Like Photoshop, rulers show the @@ -317,17 +343,21 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & // Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side). let unit = app.session.prefs().units_and_rulers.rulers; let whole = unit == Unit::Pixels; + // The zero point (#2955): rulers read 0 there and count the distance from it. + let origin = app.session.active().map_or([0.0, 0.0], |d| d.doc.ruler_origin); + let mut ticks = [1.0; 2]; for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); let (px_per_unit, step) = ruler_scale(app, xf, vertical); + ticks[usize::from(vertical)] = step * px_per_unit / 10.0; let (u0, u1) = if vertical { - ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit) + ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit, origin) } else { - ruler_range(xf, top.left_top(), top.right_top(), 0, px_per_unit) + ruler_range(xf, top.left_top(), top.right_top(), 0, px_per_unit, origin) }; let mut v = (u0 / step).floor() * step; while v <= u1 { - let at = |u: f64| -> f32 { ruler_pos(xf, vertical, u, px_per_unit) }; + let at = |u: f64| -> f32 { ruler_pos(xf, vertical, u, px_per_unit, origin) }; let c = at(v); if vertical { p.line_segment([pos2(left.right() - RULER, c), pos2(left.right(), c)], tick); @@ -360,14 +390,15 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & // Drag a new guide out of a ruler; right-click to switch the ruler unit. let mut chosen_unit: Option<&'static str> = None; for (r, vertical, salt) in [(top, false, "ruler-top"), (left, true, "ruler-left"), (corner, false, "ruler-corner")] { - // Guides drag out of the top/left rulers; all three sense clicks so the right-click unit - // menu opens anywhere on the ruler (the corner only needs the menu). - let sense = if salt == "ruler-corner" { Sense::click() } else { Sense::click_and_drag() }; - let resp = ui.interact(r, ui.id().with(salt), sense); - if resp.hovered() && salt != "ruler-corner" { + // Guides drag out of the top/left rulers and the zero point out of the corner; all three + // sense clicks so the right-click unit menu opens anywhere on the ruler. + let resp = ui.interact(r, ui.id().with(salt), Sense::click_and_drag()); + if salt == "ruler-corner" { + corner_origin(app, ui, &resp, &p, xf, origin, ticks); + } else if resp.hovered() { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } - if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) { + if let Some(pos) = resp.interact_pointer_pos().filter(|_| salt != "ruler-corner" && (resp.dragged() || resp.drag_started())) { let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; @@ -390,6 +421,41 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let _ = Pos2::ZERO; } +/// The ruler corner: drag the zero point out of it (cross hairs mark where it lands; Shift snaps +/// to the ruler ticks), double-click it to put the zero point back at the image's top-left. +fn corner_origin(app: &mut PhotocraftApp, ui: &egui::Ui, resp: &egui::Response, p: &egui::Painter, xf: &ViewXform, origin: [f64; 2], ticks: [f64; 2]) { + let id = resp.id.with("origin-drag"); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.double_clicked() { + let _ = app.run("view.resetRulerOrigin", json!({})); + return; + } + // Dropped back on the corner (or a slow click), the zero point stays where it was. + if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) { + if resp.rect.contains(pos) { + ui.data_mut(|d| d.remove_temp::<[f64; 2]>(id)); + return; + } + let shift = ui.input(|i| i.modifiers.shift); + let at = origin_drop(app, xf.to_doc(pos), shift, origin, ticks); + ui.data_mut(|d| d.insert_temp(id, at)); + // Cross hairs along the document axes through the new zero point, drawn like guides so + // they turn with a rotated view. + let t = Tokens::get(ui.ctx()); + for (vertical, pos) in [(true, at[0]), (false, at[1])] { + p.line_segment(guide_segment(xf, p.clip_rect(), vertical, pos), Stroke::new(1.0, t.text)); + } + } + if resp.drag_stopped() + && let Some(at) = ui.data_mut(|d| d.remove_temp::<[f64; 2]>(id)) + { + let round = |v: f64| (v * 1000.0).round() / 1000.0; + let _ = app.run("view.setRulerOrigin", json!({"x": round(at[0]), "y": round(at[1])})); + } +} + #[cfg(test)] mod tests { use super::*; @@ -433,6 +499,140 @@ mod tests { assert!(failures.is_empty(), "ruler drawing overlaps the origin corner: {failures:?}"); } + #[test] + fn shift_snaps_the_zero_point_to_ruler_ticks_from_the_current_origin() { + assert_eq!(snap_px_to_tick(47.0, 0.0, 10.0), 50.0); + assert_eq!(snap_px_to_tick(44.9, 0.0, 10.0), 40.0); + // Ticks run from the current zero point, so they sit at 3 + 10k. + assert_eq!(snap_px_to_tick(47.0, 3.0, 10.0), 43.0); + assert_eq!(snap_px_to_tick(-8.0, 3.0, 10.0), -7.0); + // A degenerate tick never panics or moves the point. + for tick in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(snap_px_to_tick(47.5, 3.0, tick), 47.5); + } + } + + /// Rulers alone in a 400×300 frame at 100 %, centred on a 256×128 document. + fn ruler_harness(app: PhotocraftApp) -> (egui_kittest::Harness<'static, PhotocraftApp>, Rect, ViewXform) { + let full = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let h = egui_kittest::Harness::builder() + .with_size(vec2(400.0, 300.0)) + .with_step_dt(1.0 / 60.0) + .build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + (h, full, xf) + } + + fn press(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, modifiers: egui::Modifiers) { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(2); + } + + fn drag_corner_to(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, full: Rect, to: Pos2, modifiers: egui::Modifiers) { + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerMoved(corner)); + h.run_steps(1); + press(h, corner, true, modifiers); + for p in [corner + vec2(10.0, 10.0), to] { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + } + press(h, to, false, modifiers); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); + } + + fn origin(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> [f64; 2] { + h.state().session.active().unwrap().doc.ruler_origin + } + + /// #2955: dragging out of the ruler corner sets the zero point (cross hairs while dragging), + /// the rulers then read 0 there, Shift snaps it to a tick, and double-clicking the corner + /// resets it. + #[test] + fn dragging_the_ruler_corner_sets_the_zero_point_and_double_click_resets_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + app.ui.extras.snap = false; + let (mut h, full, xf) = ruler_harness(app); + h.run_steps(2); + let target = xf.to_screen(100.0, 40.0); + drag_corner_to(&mut h, full, target, egui::Modifiers::NONE); + let o = origin(&h); + assert!((o[0] - 100.0).abs() < 0.01 && (o[1] - 40.0).abs() < 0.01, "zero point lands under the pointer: {o:?}"); + // The top ruler's "0" now sits at the new zero point, "100" (a distance, unsigned as in + // Photoshop) at the image's left edge. + h.run_steps(2); + let labels: Vec<(String, f32)> = h + .output() + .shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some((t.galley.text().to_string(), t.pos.x)) } else { None }) + .collect(); + let near = |text: &str, x: f32| labels.iter().any(|(t, lx)| t == text && (lx - (x + 2.0)).abs() < 1.0); + assert!(near("0", target.x), "a 0 label at the zero point: {labels:?}"); + assert!(near("100", xf.to_screen(0.0, 0.0).x), "100 at the image's left edge: {labels:?}"); + + // Mid-drag, cross hairs run through the pointer across the canvas. + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + let mid = xf.to_screen(150.0, 90.0); + press(&mut h, corner, true, egui::Modifiers::NONE); + for p in [corner + vec2(10.0, 10.0), mid] { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(2); + } + let crosshair = h.output().shapes.iter().any(|s| { + matches!(s.shape, egui::Shape::LineSegment { points: [a, b], .. } if (a.x - mid.x).abs() < 0.5 && (b.x - mid.x).abs() < 0.5 && (b.y - a.y).abs() > 200.0) + }); + assert!(crosshair, "a vertical cross-hair line through the pointer while dragging"); + assert_eq!(origin(&h), o, "the zero point moves on release only"); + press(&mut h, mid, false, egui::Modifiers::NONE); + assert_eq!(origin(&h), [150.0, 90.0]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(origin(&h), o, "setting the zero point is undoable"); + + // A drag dropped back on the corner leaves it alone. + drag_corner_to(&mut h, full, full.min + vec2(4.0, 4.0), egui::Modifiers::NONE); + assert_eq!(origin(&h), o); + + // Shift: ticks are 10 px apart at 100 %, measured from the current zero point (100, 40). + drag_corner_to(&mut h, full, xf.to_screen(123.0, 57.0), egui::Modifiers::SHIFT); + assert_eq!(origin(&h), [120.0, 60.0]); + + for _ in 0..40 { + h.step(); + } + let corner = Rect::from_min_size(full.min, vec2(RULER, RULER)).center(); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + } + h.run_steps(2); + assert_eq!(origin(&h), [0.0, 0.0], "double-clicking the corner resets the zero point"); + } + + /// #2955: the Info panel's X/Y are measured from the rulers' zero point. + #[test] + fn info_panel_position_reads_from_the_zero_point() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.setRulerOrigin", json!({"x": 20, "y": 5})).unwrap(); + app.hover_doc = Some([50.5, 20.5]); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(260.0, 240.0)) + .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); + h.run_steps(2); + assert!(h.query_by_label("30").is_some() && h.query_by_label("15").is_some(), "X 30, Y 15 from the zero point at (20, 5)"); + assert!(h.query_by_label("50").is_none(), "not the raw image x"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; @@ -615,23 +815,23 @@ mod tests { for rotation in [0.0f32, 30.0, 45.0, 90.0, -119.0, 180.0] { for flip in [false, true] { let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip, rotation, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, [0.0; 2]); assert!((u1 - u0 - 1600.0).abs() < 1e-2, "rotation {rotation}: top ruler covers {u0}..{u1}"); let (first, last) = if flip { (u1, u0) } else { (u0, u1) }; - assert!((ruler_pos(&xf, false, first, 1.0) - a.x).abs() < 1e-2, "rotation {rotation}: first tick"); - assert!((ruler_pos(&xf, false, last, 1.0) - b.x).abs() < 1e-2, "rotation {rotation}: last tick"); + assert!((ruler_pos(&xf, false, first, 1.0, [0.0; 2]) - a.x).abs() < 1e-2, "rotation {rotation}: first tick"); + assert!((ruler_pos(&xf, false, last, 1.0, [0.0; 2]) - b.x).abs() < 1e-2, "rotation {rotation}: last tick"); // 0 at the projected origin, 500 document px = 500 × zoom screen px further on. let origin = xf.to_screen(0.0, 0.0); - assert!((ruler_pos(&xf, false, 0.0, 1.0) - origin.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); - assert!((ruler_pos(&xf, true, 0.0, 1.0) - origin.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); + assert!((ruler_pos(&xf, false, 0.0, 1.0, [0.0; 2]) - origin.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); + assert!((ruler_pos(&xf, true, 0.0, 1.0, [0.0; 2]) - origin.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); let dir = if flip { -1.0 } else { 1.0 }; - assert!((ruler_pos(&xf, false, 500.0, 1.0) - origin.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); - assert!((ruler_pos(&xf, true, 500.0, 1.0) - origin.y - 250.0).abs() < 1e-2, "rotation {rotation}: left scale"); + assert!((ruler_pos(&xf, false, 500.0, 1.0, [0.0; 2]) - origin.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); + assert!((ruler_pos(&xf, true, 500.0, 1.0, [0.0; 2]) - origin.y - 250.0).abs() < 1e-2, "rotation {rotation}: left scale"); } } // Distances left of / above the origin read without a minus sign, as in Photoshop. let xf = ViewXform { rect, zoom: 0.5, center: [0.0, 0.0], flip: false, rotation: 0.0, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, [0.0; 2]); assert!(u0 < -500.0 && u1 > 500.0, "the view spans the origin: {u0}..{u1}"); for (step, whole) in [(100.0, true), (100.0, false), (0.25, false)] { let mut v = (u0 / step).floor() * step; @@ -645,6 +845,31 @@ mod tests { assert_eq!(ruler_label(-0.25, 0.25, false), "0.25"); } + /// #2955 with #3233: a zero point moved off the image's top-left reads 0 where the canvas draws + /// it, also on a rotated or flipped view, and the ruler keeps its full length and scale. + #[test] + fn rulers_count_from_the_zero_point_on_a_rotated_view() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let (a, b) = (rect.left_top(), rect.right_top()); + let zero = [300.0, 120.0]; + for rotation in [0.0f32, 30.0, 90.0, -119.0] { + for flip in [false, true] { + let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip, rotation, aspect: 1.0 }; + let at = xf.to_screen(zero[0] as f32, zero[1] as f32); + assert!((ruler_pos(&xf, false, 0.0, 1.0, zero) - at.x).abs() < 1e-2, "rotation {rotation}: top ruler 0"); + assert!((ruler_pos(&xf, true, 0.0, 1.0, zero) - at.y).abs() < 1e-2, "rotation {rotation}: left ruler 0"); + let (u0, u1) = ruler_range(&xf, a, b, 0, 1.0, zero); + assert!((u1 - u0 - 1600.0).abs() < 1e-2, "rotation {rotation}: top ruler covers {u0}..{u1}"); + let dir = if flip { -1.0 } else { 1.0 }; + assert!((ruler_pos(&xf, false, 500.0, 1.0, zero) - at.x - dir * 250.0).abs() < 1e-2, "rotation {rotation}: top scale"); + } + } + // Unrotated, ruler value u sits over document x = zero + u. + let xf = ViewXform { rect, zoom: 0.5, center: [1000.0, 700.0], flip: false, rotation: 0.0, aspect: 1.0 }; + assert!((ruler_pos(&xf, false, -300.0, 1.0, zero) - xf.to_screen(0.0, 0.0).x).abs() < 1e-2); + assert!((ruler_pos(&xf, true, 80.0, 1.0, zero) - xf.to_screen(0.0, 200.0).y).abs() < 1e-2); + } + /// #3277: guides belong to the document, so a rotated view draws them along the document /// line; unrotated they still span the whole clip rect. #[test] @@ -674,7 +899,7 @@ mod tests { let top = Rect::from_min_size(rect.min, vec2(rect.width(), RULER)); for flip in [false, true] { let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip, rotation: 0.0, aspect: 1.0 }; - let (u0, u1) = ruler_range(&xf, top.left_top(), top.right_top(), 0, 1.0); + let (u0, u1) = ruler_range(&xf, top.left_top(), top.right_top(), 0, 1.0, [0.0; 2]); assert!(u0 <= u1, "flip={flip}: u0 ({u0}) must be <= u1 ({u1})"); assert!(u1 - u0 > 0.0, "flip={flip}: range is empty"); } @@ -717,5 +942,10 @@ mod tests { app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); assert!((snap_to_tick(&app, &xf, true, 37.0) - 38.4).abs() < 1e-9); assert!((snap_to_tick(&app, &xf, false, 37.0) - 38.4).abs() < 1e-9); + // Ticks count from the zero point (#2955): a vertical guide follows the x origin only. + let mut app = new_app(); + app.run("view.setRulerOrigin", json!({"x": 3, "y": 0})).unwrap(); + assert_eq!(snap_to_tick(&app, &xf, true, 37.0), 33.0); + assert_eq!(snap_to_tick(&app, &xf, false, 37.0), 40.0); } } diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 9c97cd640fd..8de3fe4ec79 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -92,6 +92,7 @@ fn reset_session_brush(app: &mut PhotocraftApp) { app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush); app.session.tools.brush_gesture = None; app.session.tools.current_preset = None; + app.session.tools.brush_changed(); } fn is_selection_tool(tool: Tool) -> bool { diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index a4f64baf940..81fc0fadcf9 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1206,8 +1206,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { i if i.starts_with("edit.transform.") && t.content_aware.is_some() => Some(false), "edit.undo" => Some(steps.is_some_and(|s| s.can_undo(&Step::of(t)))), "edit.redo" => Some(steps.is_some_and(|s| !s.redo.is_empty())), - // No Toggle Last State under the box, and one box at a time. - "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), + // No Toggle Last State or history jump under the box, and one box at a time. + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). @@ -1240,9 +1240,10 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { } /// While transforming, the box owns the history for every caller (menus, shortcuts, the control -/// channel, MCP): Undo and Redo step through it, and Toggle Last State, which would change the -/// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as -/// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar. +/// channel, MCP): Undo and Redo step through it, and Toggle Last State and History state jumps, +/// which would change the document under the box, are refused. Edit › Transform's Rotate and +/// Flip turn the box itself, as in Photoshop, instead of the layer under it; committing stays +/// with ↩ and the options bar. /// `None` for other commands or when not transforming. /// The commands the open box answers itself ([`is_enabled`], [`intercept`]). fn box_command(id: &str) -> bool { @@ -1257,7 +1258,9 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), - "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp" => Some(Err("Commit or cancel the transform first".into())), + "edit.toggleLastState" | "history.selectState" | "edit.contentAwareScale" | "edit.puppetWarp" => { + Some(Err("Commit or cancel the transform first".into())) + } _ => { let (kx, ky, angle) = box_preset(id)?; if warping { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f18c4730e24..793257be8b3 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -996,6 +996,10 @@ pub fn reset_form(f: &mut Map) { } fn label_of(key: &str) -> String { + // A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase. + if key.contains(' ') { + return tl!(key).to_owned(); + } let mut out = String::new(); for (i, c) in key.chars().enumerate() { if i == 0 { @@ -1026,6 +1030,7 @@ mod label_tests { crate::i18n::with_language(crate::i18n::Lang::EN, || { assert_eq!(label_of("useAntialias"), "Use antialias"); assert_eq!(label_of("radius"), "Radius"); + assert_eq!(label_of("iPhone 14 Pro Max"), "iPhone 14 Pro Max"); }); } } @@ -1060,6 +1065,16 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); + if k == "naming" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { + let label = ui.label(tl!("File Naming")); + let mut naming = v.as_str().unwrap_or_default().to_owned(); + ui.add(egui::TextEdit::singleline(&mut naming).desired_width(260.0).hint_text("{name}")) + .labelled_by(label.id) + .on_hover_text("{name} · {serial} · {date} (UTC)"); + f.insert(k, json!(naming)); + ui.end_row(); + continue; + } if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { ui.label(label(&k)); let mut custom = v.as_f64().is_some(); @@ -1309,7 +1324,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("starting serial number control") + .click(); + h.run_steps(2); + h.event(egui::Event::Text("7".into())); + h.run_steps(3); + h.get_by_label("Starting serial").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["startingSerial"], 7, "typing changes the starting serial"); + evidence(&mut h, "batch-naming"); + h.get_by_label("OK").click(); + h.run_steps(5); + assert!(h.state().ui.dialogs.is_empty()); + let expected: Vec<_> = source.data().iter().map(|v| 255 - v).collect(); + for name in ["007_first.png", "008_second.png"] { + let bytes = std::fs::read(output.join(name)).expect("dialog naming reaches the exporter"); + let decoded = photocraft_codecs::decode_as(Format::Png, &bytes).unwrap(); + assert_eq!(decoded.data(), expected, "the recorded Invert action still runs"); + } + assert!(!output.join("first.png").exists()); + assert!(!output.join("second.png").exists()); +} + #[test] fn batch_default_quality_keeps_lossless_webp_and_existing_jpeg_bytes() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/docs/batch-naming.md b/docs/batch-naming.md new file mode 100644 index 00000000000..a922803f81e --- /dev/null +++ b/docs/batch-naming.md @@ -0,0 +1,41 @@ +# Batch output naming + +Select a recorded action, then open **File > Automate > Batch**. Set the input and output +folders. **File Naming** accepts a filename stem template; the selected export format supplies +the extension. Leave it blank to keep the existing source filenames and collision reporting. + +| Token | Value | +| --- | --- | +| `{name}` | Input filename without its last extension | +| `{serial}` | Input position plus **Starting serial** (default 1), padded to at least three digits | +| `{date}` | UTC date at the start of the batch, in `YYYY-MM-DD` form | + +For example, `{name}_{serial}` with starting serial 7 writes `photo_007.png` and +`photo_008.png` for two PNG inputs named `photo.png`. A serial after 999 becomes 1000; +it does not wrap. Folder inputs use the existing filename order, and an explicit input array +keeps its order. Failed inputs consume their position, so later filenames do not shift. +Braces in an input filename are literal text, not additional tokens. + +Unknown tokens, unmatched braces, invalid parameter types, path separators, reserved filename +characters, excessive filename length (255 UTF-8 bytes), and serial overflow are rejected before +export writes begin. Starting serial must be an integer from 1 to 4,294,967,295. +A custom template that resolves to an existing input file is also rejected; choose a separate +destination or another template. Ordinary per-file read, action and save failures still appear +in the batch's `errors` result. When two results share a name, including a case-only difference, +the first successful result is kept and the later collision is reported. + +The same options are available on `file.automate.batch` through the engine command interface: + +```json +{ + "input": ["first/photo.png", "second/photo.png"], + "output": "exports", + "steps": [["image.adjustments.invert", {}]], + "format": "png", + "naming": "{name}_{serial}", + "startingSerial": 7 +} +``` + +The CLI can invoke this command with `run --cmd file.automate.batch --params ...`. +This option belongs to Batch; Image Processor and droplet options retain their existing behavior. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 256f377605e..771fec92e9c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,7 +45,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i ## Methods - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) -- `engine.commands`: list commands with enablement +- `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies)) - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) - `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. @@ -94,7 +94,7 @@ Change the UI language without restarting with `prefs.set`: are documented in [UI localisation](localization.md). `prefs.get` and the existing preference store expose and persist the same setting; scripts keep using canonical command IDs. -`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled. Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: +`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled (reply shape: [Command list replies](#command-list-replies)). Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: | Command | Params | |---|---| @@ -174,6 +174,35 @@ their usual file access. Source replacement, relinking and export commands remai double-clicking its thumbnail in Layers. Text input, Commit and Cancel use the existing Type tool editing session; non-type layers return an error without changing the tool or document. +### Command list replies + +`engine.commands` (control channel and `serve`), the engine command `command.list` and the MCP +tool `command_list` all reply with a bare JSON array, not an object: there is no +`{"commands": [...]}` wrapper. On the control channel and `serve` the array is the reply's +`result` (`{"id":1,"ok":true,"result":[…]}`); the MCP tool returns it as its JSON text. +Each element describes one command, in registry order: + +```json +{"id":"file.new","label":"New…","menu":["File"],"shortcut":"Cmd+N","params":"{\"width\":1..300000=1920,…}","enabled":true,"disabledReason":null,"needsDocument":false} +``` + +| Key | Type | Meaning | +|---|---|---| +| `id` | string | The id to pass to `engine.execute` / `command_run` | +| `label` | string | Menu label | +| `menu` | array of strings | Menu path above the item (`[]` for commands without a menu item) | +| `shortcut` | string or `null` | Default shortcut, e.g. `"Cmd+Shift+X"` | +| `params` | string | Human-readable parameter doc (not a JSON schema) | +| `enabled` | boolean | Whether the command can run in the current state | +| `disabledReason` | string or `null` | Why the command can't run now (`null` when `enabled`) | +| `needsDocument` | boolean | `true` when it is off because no document is open (#3664) | + +Filtering narrows the same array without changing its elements: `serve`'s `engine.commands +{filter}` and the MCP tool's `filter` keep commands whose id or label contains the text +(case-insensitive), and the MCP tool's `enabled_only` keeps those with `enabled: true`. The +desktop control channel's `engine.commands` takes no filter and always returns every command. +The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). + ### Background jobs (#210) Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. @@ -267,7 +296,7 @@ How each MCP tool maps onto control methods in bridge mode: |---|---| | `command_run {id, params, wait?}` | `engine.execute {command: id, params, wait}` | | `jobs_list` / `jobs_cancel {job?}` | `jobs.list` / `jobs.cancel {job?}` | -| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server; rows keep `disabledReason` and `needsDocument`) | +| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server; replies with the same bare array, rows keep `disabledReason` and `needsDocument`, see [Command list replies](#command-list-replies)) | | `doc_new {…}` | `engine.execute {command: "file.new", params}` | | `doc_inspect` | `engine.execute {command: "document.inspect"}` | | `doc_open {path}` | `app.open {path}` | @@ -325,7 +354,7 @@ no MCP framing, no app start-up per command. Configure its file access with the |---|---| | `engine.execute` | `{command, params?, wait?}`: any engine command (`wait: false` starts a long one as a background job: `{job, pending}`) | | `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | -| `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`) | +| `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`); `result` is a bare array of command objects ([Command list replies](#command-list-replies)) | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | | `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | diff --git a/docs/development.md b/docs/development.md index bb0de12690c..17c7e67dce2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,7 +82,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering it in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state. On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). diff --git a/docs/font-resolution.md b/docs/font-resolution.md new file mode 100644 index 00000000000..e1f1e273f74 --- /dev/null +++ b/docs/font-resolution.md @@ -0,0 +1,42 @@ +# PostScript font resolution + +PSD text may identify a face by its PostScript name as well as a family/style. +The PostScript prefix need not match the family name, and the family stored with +a run can be a guess made from that name. + +## Resolution + +`FontDb::resolve_postscript` first looks for an installed face with that exact +PostScript name (families that share the guessed prefix are read first), then +falls back to a heuristic split of the name. The heuristic splits at the last +hyphen. A name without a hyphen loses a trailing capitalised style word +(`GmarketSansBold` → Gmarket Sans, 700; `ExampleSansSemiBoldItalic` → Example +Sans, 600, italic). A lowercase ending is not a style (`Daylight`), and a style +word after the vendor tag `MT` stays in the family (`ArialRoundedMTBold` → +Arial Rounded MT Bold). + +The heuristic also gives the family, weight and slant stored on PSD import when +the exact face is absent. A legacy family whose name ends in a style word and +has no `MT` tag before it (`CooperBlack`) is then stored as the shorter family +(Cooper, 900) until the face is installed. + +## Missing fonts + +Opening a PSD or Affinity file already replaces a run's guessed family with the +installed family its PostScript name resolves to. A guess still stays on a run +when the face was absent at import and the document is reopened from `.pcraft` +after the font is installed, when fonts are registered after opening, or when a +command sets `font` and `postscriptName` together. + +Missing-font detection (`missing_fonts` and `missing_fonts_in_layer`) and Replace +Missing Fonts therefore use the same decision as layout: a run is missing only +when neither its stored family, its exact PostScript face nor the heuristic +family is installed. A run that resolves is not replaced because an unresolved +run shares its family label. + +## Cost + +Detection resolves the PostScript name only for runs whose stored family is not +installed. The first such lookup reads the name tables of installed faces; the +result is cached until fonts are registered or system fonts are loaded, and +layout performs the same lookup for those runs. diff --git a/docs/gaps.md b/docs/gaps.md index c60045cab08..5653e5617e4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; 2026-10-11: G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; 2026-10-11: G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -70,6 +70,8 @@ what an agent can't finish alone. (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, TOOL-214-7, are done). + Blur Gallery: Field Blur has its on-canvas pins (2026-10-11, #3373); Iris, Tilt-Shift, Spin and + Path Blur still edit their pins as parameter dialogs. - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -88,7 +90,10 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming +- **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using + `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding + names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. +- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion, open-document inputs, progress/cancel and an error-log file (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps @@ -271,6 +276,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | +| 2026-10-11 | minor | G7: Field Blur pins are placed, dragged and tuned on the image (#3373); the other Blur Gallery filters remain dialogs | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | diff --git a/docs/mcp.md b/docs/mcp.md index 4f7e9cc7043..3046a669bc8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -13,7 +13,9 @@ described in [development.md](development.md). UI tools require a running deskto ## Tools and resources - `command_list`: discover engine ids, parameter docs and current enabled state; accepts - `filter` and `enabled_only`. + `filter` and `enabled_only`. Returns a bare JSON array of + `{id, label, menu, shortcut, params, enabled, disabledReason, needsDocument}` objects (see + [Command list replies](control-protocol.md#command-list-replies)). - `command_run`: `id`, optional `params` and `wait`. Command parameters remain engine-defined. - `command_batch`: ordered `steps`, optional `stop_on_error` (default true); stops at the first error unless false. Not an atomic transaction: earlier successful steps stay applied, with diff --git a/docs/roadmap.md b/docs/roadmap.md index e14a05113e8..bca91616c7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Photomerge implementation status corrected; real-world quality validation remains open) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (adaptive Unsharp Mask performance evidence linked; Photomerge status correction retained; alpha gate, focus and estimates unchanged) · **Target:** Adobe Photoshop 2026 - UI.5: Panels can detach, join other tab groups, split, resize and redock through shared docking transactions. Custom layouts persist with workspaces, respect workspace locking, and retain collapse flags for intact native groups. @@ -72,6 +72,9 @@ Ranked; each item links to its gap entry. Pick from the top unless an issue is a Photoshop re-open check (FILE-216-4); Photoshop-oracle floor 133/256 → 200+. 150–250 h. 2. **Performance budgets** ([G2](gaps.md#g2-interactive-performance)): the 22 over-budget scenarios, the P12 GPU crash at 150 layers, brush and cursor lag (#2619, #2566). 200–350 h. + Local adaptive [Unsharp Mask optimisation](unsharp-mask-performance.md) improves native + threshold-zero 24 MP RGBA CPU application by 37–102x at radii 300–1000, with measured + rounding differences and an exact fallback; no published budget is closed by that measurement. 3. **User bug backlog** ([G3](gaps.md#g3-user-reported-bug-backlog)): 579 open issues, most from 0.5/0.6 users; triage weekly, fix regressions first. 250–400 h. 4. **Brush and transform feel** ([G4](gaps.md#g4-brush-feel), [G5](gaps.md#g5-transform-handles-and-modifiers)): @@ -113,6 +116,7 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Linked adaptive Unsharp Mask performance and quality evidence; milestones, alpha gate and estimates unchanged | | 2026-10-10 | minor | Photomerge is implemented; real-world quality validation remains open | | 2026-10-10 | minor | Alpha gate table (core-workflow gate from the progress-docs standard): passes, stage stays alpha | | 2026-10-10 | major | Progress-docs standard: honest assessment and dated measurements moved to `target-app-parity.md`, Affinity notes to `file-format-parity.md`; new Current focus and estimates; `parity.md` renamed `parity-checklist.md` | diff --git a/docs/scorecard.md b/docs/scorecard.md index 75b056460ef..b915fd75952 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,7 +176,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 157** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. @@ -231,7 +231,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | +| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 88923e1d77e..28c38ccf22c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -184,6 +184,8 @@ parity for the area and are already contained in the dimension rows above. | Workspace, panels, preferences | 40% | 150–250 | 46 of 149 prefs do nothing; no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | +Batch supports [output naming templates](batch-naming.md); AUTO-219-5 remains partial. + ## Remaining effort and calibration **Calibration.** The repository's history from the first commit (2026-09-30) to 2026-10-10: 1,152 diff --git a/docs/unsharp-mask-performance.md b/docs/unsharp-mask-performance.md new file mode 100644 index 00000000000..d530cf2f938 --- /dev/null +++ b/docs/unsharp-mask-performance.md @@ -0,0 +1,23 @@ +# Unsharp Mask performance + +Large-radius native Unsharp Mask uses separable FFT convolution with the original +discrete Gaussian coefficients and the existing `rustfft` dependency. A streamed +separable path preserves the original tap order for exact processing. + +FFT is eligible for radii 64–1000 with at least 65,536 output pixels, a nonzero +amount and a zero or negative threshold. Positive thresholds, unstable samples, +unsupported geometry and insufficient scratch use the exact path. WebAssembly +retains exact processing. Alpha, selections, edges and cancellation are preserved. +Smart Sharpen, High Pass and shared blur math are unchanged. + +FFT changes summation order and permits small rounding differences. The measured +maximum was one 8-bit storage level, approximately one 16-bit level, and 8.762e-6 +for the HDR fixture. Exact-path comparisons were byte-identical. Transform axes +are capped at 32,768; FFT scratch grouping has a 1 GiB budget, excluding document +tiles. This is not a whole-process memory limit. + +Before/after numbers, benchmark methodology, numerical tolerances and preview +measurement limits are recorded in [PR #2987](https://github.com/storytold/photocraft/pull/2987). +The comparisons use synthetic images and measure CPU computation, not slider +event-to-screen latency. No published performance budget or rendering-parity +floor is raised by these measurements. diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 0de2ab3bd2b..7bec4ab4431 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -44,7 +44,7 @@ id = "AUTO-219-5" target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop" status = "partial" issue = 219 -note = "file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel" +note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial; no recursion, open-documents source, cancel or error-log file" [[item]] id = "AUTO-219-6"