diff --git a/Cargo.lock b/Cargo.lock index f2ebdb2198..dc725c05f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5564,6 +5564,7 @@ name = "photocraft-ui-egui" version = "0.20.1" dependencies = [ "base64 0.23.1", + "chrono", "craft-print", "craft-ui", "craft-update", @@ -5629,6 +5630,7 @@ dependencies = [ "photocraft-io", "photocraft-paint", "photocraft-psd", + "photocraft-raster", "photocraft-ui-egui", "rfd", "serde_json", diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html index 8ed9a6961e..7d15b18daa 100644 --- a/apps/photocraft-web-boot/index.html +++ b/apps/photocraft-web-boot/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 0176e0fe84..1811aea283 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -24,6 +24,7 @@ photocraft-io = { workspace = true } photocraft-codecs = { workspace = true } photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true } +photocraft-raster = { workspace = true } egui = { workspace = true } log = { workspace = true } @@ -34,7 +35,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator", "ErrorEvent", "PromiseRejectionEvent"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/index.html b/apps/photocraft-web/index.html index d090cf47da..30ea522b66 100644 --- a/apps/photocraft-web/index.html +++ b/apps/photocraft-web/index.html @@ -16,6 +16,15 @@ this page, no Rust runs to say so, so the page itself shows what to do after a while. */ #photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; } @keyframes photocraft-stalled { to { visibility: visible; } } + /* Revealed by the app when the module traps (an allocation it could not satisfy, or a panic): + the page would otherwise sit on the last frame, frozen. Static text, so it shows even if Rust + can no longer build any. */ + #photocraft_stopped { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 0 16px; text-align: center; color: #e0e0e0; background: rgba(38, 38, 38, 0.92); font: 14px system-ui, sans-serif; pointer-events: auto; } + #photocraft_stopped[hidden] { display: none; } + #photocraft_stopped p { margin: 0 0 8px; max-width: 460px; line-height: 1.5; } + #photocraft_stopped a { color: #ffffff; } + #photocraft_stopped_detail { color: #b8b8b8; } @@ -26,5 +35,10 @@ download or come from a different version of this page. Reload to try again; the browser console lists what failed.

+ diff --git a/apps/photocraft-web/src/diagnostics.rs b/apps/photocraft-web/src/diagnostics.rs new file mode 100644 index 0000000000..134e0e4035 --- /dev/null +++ b/apps/photocraft-web/src/diagnostics.rs @@ -0,0 +1,148 @@ +//! What the page says when the module stops, and the `?free-mb` diagnostic flag (#10024). +//! +//! A failed allocation on `wasm32-unknown-unknown` aborts the module: the page keeps the last +//! frame and nothing says why. `web.rs` listens for the trap in the page and shows the static +//! `#photocraft_stopped` element; the decisions that need no browser live here, so native tests +//! cover them. + +use std::sync::{Mutex, PoisonError}; + +/// The message of the last panic, kept for the trap that follows it (a panic aborts the module). +static LAST_PANIC: Mutex = Mutex::new(String::new()); + +/// The most characters of a panic message the page shows. +const MAX_PANIC_CHARS: usize = 300; + +/// Truncates `message` to [`MAX_PANIC_CHARS`] characters, on a character boundary. +pub fn truncate(message: &str) -> String { + match message.char_indices().nth(MAX_PANIC_CHARS) { + Some((end, _)) => format!("{}\u{2026}", message.get(..end).unwrap_or_default()), + None => message.to_owned(), + } +} + +/// Remembers a panic's message (and where it happened) for [`take_panic`]. +pub fn record_panic(message: &str) { + *LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner) = truncate(message); +} + +/// The message [`record_panic`] kept, if any. +pub fn take_panic() -> Option { + let mut last = LAST_PANIC.lock().unwrap_or_else(PoisonError::into_inner); + (!last.is_empty()).then(|| std::mem::take(&mut *last)) +} + +/// `true` when an uncaught error, by its `name` and `message`, is the module trapping or the +/// browser refusing it memory, rather than some other script error or failed request. Browsers +/// word and capitalize these differently (Safari: `Unreachable code should not be executed`), so +/// the message is matched ignoring case. +pub fn is_trap(name: &str, message: &str) -> bool { + let message = message.to_ascii_lowercase(); + name == "RuntimeError" + || ["unreachable", "memory access out of bounds", "maximum call stack", "could not allocate memory", "out of memory"] + .iter() + .any(|m| message.contains(m)) +} + +/// Trap messages (lowercase, matched ignoring case) that mean an allocation failed: +/// `unreachable` with no panic before it is the allocation-failure abort (the only one that runs +/// no Rust), the others are the browser refusing to grow the memory. `memory access out of +/// bounds` is not among them: it is a bug. +const OUT_OF_MEMORY: [&str; 3] = ["unreachable", "could not allocate memory", "out of memory"]; + +/// The line under "PhotoCraft stopped": a captured panic means an internal error; a trap with no +/// panic before it is out of memory when its message says so (or says nothing), and otherwise +/// unexpected. +pub fn detail(panic: Option<&str>, trap_message: &str) -> String { + if let Some(p) = panic { + return format!("It hit an internal error: {p}"); + } + let lower = trap_message.to_ascii_lowercase(); + if lower.is_empty() || OUT_OF_MEMORY.iter().any(|m| lower.contains(m)) { + "It ran out of memory.".to_owned() + } else { + format!("It stopped unexpectedly: {}", truncate(trap_message)) + } +} + +/// `?free-mb=N` from a URL query (`?cpu&free-mb=300`): pretend only N MiB can be allocated, so +/// the memory gates and the low-memory warning can be seen without gigabytes of images. +pub fn free_mb(query: &str) -> Option { + query.trim_start_matches('?').split('&').find_map(|pair| pair.strip_prefix("free-mb=")?.parse().ok()) +} + +/// The bytes `free-mb` asks for, without overflowing. +pub fn free_bytes(mb: u64) -> u64 { + mb.saturating_mul(1 << 20) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_panic_message_is_shown_as_an_internal_error() { + assert_eq!(detail(Some("index out of bounds"), "unreachable"), "It hit an internal error: index out of bounds"); + } + + #[test] + fn a_trap_with_no_panic_is_an_out_of_memory_abort() { + assert_eq!(detail(None, "unreachable"), "It ran out of memory."); + assert_eq!(detail(None, "RuntimeError: unreachable executed"), "It ran out of memory."); + assert_eq!(detail(None, ""), "It ran out of memory."); + assert_eq!(detail(None, "WebAssembly.Memory(): could not allocate memory"), "It ran out of memory."); + // Safari and Firefox word and capitalize theirs differently. + assert_eq!(detail(None, "Unreachable code should not be executed"), "It ran out of memory."); + assert_eq!(detail(None, "out of memory"), "It ran out of memory."); + } + + #[test] + fn another_kind_of_trap_is_not_blamed_on_memory() { + let text = detail(None, "divide by zero"); + assert!(text.starts_with("It stopped unexpectedly: divide by zero"), "{text}"); + // A bad memory access is a bug, not memory running out. + let text = detail(None, "memory access out of bounds"); + assert!(text.starts_with("It stopped unexpectedly: memory access out of bounds"), "{text}"); + } + + #[test] + fn only_traps_count() { + assert!(is_trap("RuntimeError", "unreachable")); + assert!(is_trap("RuntimeError", "")); + assert!(is_trap("RangeError", "Maximum call stack size exceeded")); + assert!(is_trap("RangeError", "WebAssembly.Memory(): could not allocate memory")); + // Matched ignoring case: Safari's wording, reported without the RuntimeError name. + assert!(is_trap("Error", "Unreachable code should not be executed")); + assert!(is_trap("Error", "Out of memory")); + assert!(!is_trap("TypeError", "Failed to fetch")); + assert!(!is_trap("Error", "ResizeObserver loop completed")); + } + + #[test] + fn long_messages_are_cut_on_a_character_boundary() { + let long = "\u{e9}".repeat(1000); + let cut = truncate(&long); + assert_eq!(cut.chars().count(), MAX_PANIC_CHARS + 1); + assert!(cut.ends_with('\u{2026}')); + assert_eq!(truncate("short"), "short"); + assert_eq!(truncate(&"x".repeat(MAX_PANIC_CHARS)).chars().count(), MAX_PANIC_CHARS); + } + + #[test] + fn the_recorded_panic_is_taken_once() { + record_panic("boom at a.rs:1"); + assert_eq!(take_panic().as_deref(), Some("boom at a.rs:1")); + assert_eq!(take_panic(), None); + } + + #[test] + fn free_mb_is_read_from_the_query() { + assert_eq!(free_mb(""), None); + assert_eq!(free_mb("?cpu"), None); + assert_eq!(free_mb("?free-mb=300"), Some(300)); + assert_eq!(free_mb("?cpu&free-mb=400&webgl"), Some(400)); + assert_eq!(free_mb("?free-mb=lots"), None); + assert_eq!(free_bytes(300), 300 << 20); + assert_eq!(free_bytes(u64::MAX), u64::MAX); + } +} diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 0802a02ebf..60846196f1 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -12,10 +12,15 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`. //! //! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); -//! `?webgl` forces the WebGL2 backend instead of WebGPU. +//! `?webgl` forces the WebGL2 backend instead of WebGPU; `?free-mb=N` pretends only N MiB can be +//! allocated, so the memory gates and the low-memory warning can be seen without gigabytes of +//! images (diagnostics only). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod diagnostics; + #[cfg(any(target_arch = "wasm32", test))] mod preset_bridge; @@ -48,4 +53,22 @@ mod tests { assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay"); assert!(!html.contains("").expect("the element is closed"); + assert!(element.trim_start().starts_with("hidden"), "hidden until a trap: {element}"); + assert!(element.contains("role=\"alert\""), "{element}"); + assert!(element.contains("Reload"), "{element}"); + assert!(element.contains("can't be recovered"), "says what is lost: {element}"); + assert!(element.contains("id=\"photocraft_stopped_detail\""), "a line for the cause: {element}"); + assert!(html.contains("#photocraft_stopped[hidden]"), "display: flex must not defeat the hidden attribute"); + assert!(html.contains("pointer-events: auto"), "the link works over the canvas"); + assert!(!html.contains(")>>>; +type PlaceInbox = photocraft_ui_egui::PlaceInbox; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries. @@ -25,6 +26,8 @@ const OPEN_EXTS: &[&str] = &[ "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; +/// Set once the module has trapped: the page is dead, so nothing is left to protect. +static STOPPED: AtomicBool = AtomicBool::new(false); const CANVAS_ID: &str = "photocraft_canvas"; /// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest @@ -57,6 +60,10 @@ pub fn start() { }; let q = query(); let force_cpu = q.contains("cpu"); + // `?free-mb=N`: the memory gates and the low-memory warning see only N MiB (diagnostics). + if let Some(mb) = crate::diagnostics::free_mb(&q) { + photocraft_raster::headroom::simulate_available(Some(crate::diagnostics::free_bytes(mb))); + } let mut options = eframe::WebOptions::default(); photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup); if q.contains("webgl") @@ -75,7 +82,13 @@ pub fn start() { } }; let pen_target = canvas.clone(); - let result = eframe::WebRunner::new() + let runner = eframe::WebRunner::new(); + // After the runner's own panic handler, so the message is kept and the runner's handler + // still runs (a panic aborts the module). + keep_panic_message(); + // Before the app is built: a trap while building it shows the stopped message too. + listen_for_trap(); + let result = runner .start( canvas, options, @@ -83,7 +96,7 @@ pub fn start() { PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); - let place_inbox: Inbox = Arc::default(); + let place_inbox: PlaceInbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); @@ -180,11 +193,15 @@ fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocr /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is -/// refreshed every frame by [`WebShell`]. +/// refreshed every frame by [`WebShell`]. Once the module has stopped ([`STOPPED`]) the prompt is +/// skipped: the document can't be saved any more, and Reload is the only way on. fn guard_unload(unsaved: Arc) { use wasm_bindgen::closure::Closure; let Some(window) = web_sys::window() else { return }; let cb = Closure::::new(move |e: web_sys::BeforeUnloadEvent| { + if STOPPED.load(Ordering::Relaxed) { + return; + } if unsaved.load(Ordering::Relaxed) { e.prevent_default(); // Older browsers show the prompt only when a return value is set. @@ -196,6 +213,67 @@ fn guard_unload(unsaved: Arc) { } } +/// Keeps the message of a panic, then calls the handler that was installed before (the web +/// runner's, which logs it). A panic aborts the module, so the trap that follows has no message of +/// its own; this is what lets the stopped page tell an internal error from running out of memory. +fn keep_panic_message() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + crate::diagnostics::record_panic(&info.to_string()); + previous(info); + })); +} + +/// Shows the page's static `#photocraft_stopped` message when the module traps. The page learns of +/// a trap as an uncaught `WebAssembly.RuntimeError` (the `error` event) or, inside a spawned +/// future, an unhandled rejection. The module may be mid-call and its runner still borrowed, so +/// the handler touches no app state: it sets [`STOPPED`] and the element, nothing more. +fn listen_for_trap() { + use wasm_bindgen::closure::Closure; + let Some(window) = web_sys::window() else { return }; + let on_error = Closure::::new(move |e: web_sys::ErrorEvent| { + let error = e.error(); + let (name, message) = error_parts(&error, &e.message()); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref()).is_ok() { + on_error.forget(); + } + let on_rejection = Closure::::new(move |e: web_sys::PromiseRejectionEvent| { + let (name, message) = error_parts(&e.reason(), ""); + if crate::diagnostics::is_trap(&name, &message) { + show_stopped(&message); + } + }); + if window.add_event_listener_with_callback("unhandledrejection", on_rejection.as_ref().unchecked_ref()).is_ok() { + on_rejection.forget(); + } +} + +/// The name and message of a thrown value; `fallback` is the event's own message when the value +/// is not an `Error`. +fn error_parts(value: &wasm_bindgen::JsValue, fallback: &str) -> (String, String) { + match value.dyn_ref::() { + Some(error) => (String::from(error.name()), String::from(error.message())), + None => (String::new(), value.as_string().unwrap_or_else(|| fallback.to_owned())), + } +} + +/// Reveals `#photocraft_stopped` with the cause. Does as little as possible. +fn show_stopped(trap_message: &str) { + STOPPED.store(true, Ordering::Relaxed); + let detail = crate::diagnostics::detail(crate::diagnostics::take_panic().as_deref(), trap_message); + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + if let Some(el) = document.get_element_by_id("photocraft_stopped_detail") { + el.set_text_content(Some(&detail)); + } + if let Some(el) = document.get_element_by_id("photocraft_stopped") { + let _ = el.remove_attribute("hidden"); + } +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -267,7 +345,7 @@ struct WebShell { /// Dropped files to open as documents. inbox: Inbox, /// Dropped files to place on the active document's canvas. - place_inbox: Inbox, + place_inbox: PlaceInbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -354,16 +432,22 @@ impl eframe::App for WebShell { // Decided now, where the drop landed: on the document's canvas the files are placed // as layers (as on the desktop), elsewhere opened. let places = self.app.dropped_on_canvas(ctx, &names); - let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let target_doc = self.app.session.active().map(|st| st.doc.id); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).map(|((f, name), place)| (f, name, place, target_doc)).collect(); let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - for ((f, name), place) in files { + for (f, name, place, target_doc) in files { match f.bytes_async().await { Ok(bytes) => { - let queue = if place { &place_inbox } else { &inbox }; - queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + if place { + if let Some(doc) = target_doc { + place_inbox.lock().unwrap_or_else(PoisonError::into_inner).push((doc, name, bytes)); + } + } else { + inbox.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); + } ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -407,7 +491,7 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| { @@ -465,6 +549,9 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), + load_gpu_memo: None, + save_gpu_memo: None, + shader_compiler: None, ..Default::default() } } diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs index cf2ede3b7b..562dcf76e3 100644 --- a/apps/photocraft/src/icon_theme.rs +++ b/apps/photocraft/src/icon_theme.rs @@ -202,17 +202,22 @@ pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_data(svg, &opts).ok()?; - let size = tree.size(); - let side = size.width().max(size.height()); - if !side.is_finite() || side <= 0.0 { - return None; - } - let s = px as f32 / side; - let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); - let mut pixmap = tiny_skia::Pixmap::new(px, px)?; - resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); - Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + // usvg and resvg panic on some malformed drawings: such an icon is unreadable, not a crash. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) + })) + .ok() + .flatten() } #[cfg(test)] @@ -303,6 +308,15 @@ mod tests { assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); } + /// resvg panics rendering an offset past the pixmap's size: the icon is unreadable instead. + #[test] + fn a_mask_the_renderer_panics_on_is_none() { + let svg = r##" + + "##; + assert!(rasterize_mask(svg.as_bytes(), 16).is_none()); + } + /// An icon's `` is never loaded: not a device file, not an embedded picture. #[test] fn masks_load_no_images() { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 75eb0ea83a..41d2faedee 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -588,6 +588,7 @@ fn main() -> eframe::Result { && app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu { app.set_wgpu(rs); + app.start_compositor(&cc.egui_ctx); } else { // The window still draws with wgpu: record its errors instead of panicking, // and notice when the device is lost (#3438). diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 7741a8d8a3..545fc86eec 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -341,6 +341,41 @@ pub fn prefs_file() -> Option { config_dir().map(|d| d.join("preferences.json")) } +fn gpu_memo_file() -> Option { + config_dir().map(|d| d.join("gpu-compositor.json")) +} + +fn load_gpu_memo_text() -> Option { + use std::io::Read; + let file = std::fs::File::open(gpu_memo_file()?).ok()?; + let cap = photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES; + let mut bytes = Vec::new(); + file.take((cap + 1) as u64).read_to_end(&mut bytes).ok()?; + if bytes.len() > cap { + log::info!("ignoring oversized GPU compositor memo"); + return None; + } + String::from_utf8(bytes).ok() +} + +fn shader_compiler_identity() -> String { + #[cfg(target_os = "windows")] + { + let exe = std::env::current_exe().ok(); + let exe_dir = exe.as_deref().and_then(Path::parent); + match crate::gpu_startup::dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref(), exe_dir) { + eframe::wgpu::Dx12Compiler::Fxc => "fxc".into(), + eframe::wgpu::Dx12Compiler::DynamicDxc { dxc_path } => format!("dxc:{dxc_path}"), + eframe::wgpu::Dx12Compiler::StaticDxc => "static-dxc".into(), + eframe::wgpu::Dx12Compiler::Auto => "auto".into(), + } + } + #[cfg(not(target_os = "windows"))] + { + String::new() + } +} + /// The brush preset store (one file per preset group plus tip bitmaps; see /// `photocraft_engine::preset_store`). pub fn presets_dir() -> Option { @@ -791,6 +826,14 @@ pub fn native(automation: Option) -> load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), + load_gpu_memo: Some(Box::new(load_gpu_memo_text)), + save_gpu_memo: Some(Box::new(|text: &str| { + if text.len() > photocraft_ui_egui::gpu_memo::MAX_GPU_MEMO_BYTES { + return Err("GPU compositor memo exceeds 64 KiB".into()); + } + write_atomic(&gpu_memo_file().ok_or("no config directory")?, text.as_bytes()) + })), + shader_compiler: Some(shader_compiler_identity()), system_theme: crate::appearance::service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; diff --git a/crates/algo/examples/bench_remove_targets.rs b/crates/algo/examples/bench_remove_targets.rs new file mode 100644 index 0000000000..780875a1dc --- /dev/null +++ b/crates/algo/examples/bench_remove_targets.rs @@ -0,0 +1,162 @@ +//! Timing of the Remove Tool's target-mask construction (the dilation of the hole by the patch +//! radius, once per pyramid level) and of the whole fill, on a 24 or 36 MP RGBA f32 image. One +//! JSON line per repetition. Uses only APIs that exist before and after the change, so the same +//! file builds in a base checkout. +//! `cargo run --release -p photocraft-algo --example bench_remove_targets -- --mode phase --mp 24 --hole small --reps 20` +//! +//! * `--mode phase|e2e`: `phase` times the masks alone (`old_loop`, a verbatim copy of the loop +//! the completion used to run, against `remove_dilate`); `e2e` times `remove_with`. +//! * `--hole small|scattered|large|dense`: a 48 px disc; about 20 single cells spread over the +//! image; a centred ellipse (`--large 900x600` sets its extent); half the cells of a 2000x2000 +//! square (`phase` only). +use std::time::Instant; + +use photocraft_algo::remove::{dilate, hole_radius, remove_with}; +use photocraft_raster::Interrupt; + +/// The patch radius of the completion's default parameters. +const R: usize = 3; + +fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn hash(i: u64) -> u64 { + let mut x = i.wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +fn fnv(bytes: impl Iterator) -> u64 { + bytes.fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)) +} + +fn texture(w: usize, h: usize) -> Vec { + let mut img = Vec::with_capacity(w * h * 4); + for i in 0..w * h { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let n = (hash(i as u64) >> 40) as f32 / (1u64 << 24) as f32; + let v = 0.5 + 0.2 * (x * 0.05).sin() * (y * 0.07).cos() + 0.15 * n; + img.extend_from_slice(&[v, v * 0.8 + 0.05 * (x * 0.01).cos(), v * 0.6, 1.0]); + } + img +} + +fn hole_mask(kind: &str, w: usize, h: usize, large: (usize, usize)) -> Vec { + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut m = vec![false; w * h]; + match kind { + "small" => (0..w * h).for_each(|i| m[i] = ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < 24.0), + "scattered" => { + for k in 0..20u64 { + let (x, y) = (hash(k * 2) as usize % w, hash(k * 2 + 1) as usize % h); + m[y * w + x] = true; + } + } + "large" => { + let (a, b) = (large.0 as f32 / 2.0, large.1 as f32 / 2.0); + (0..w * h).for_each(|i| m[i] = (((i % w) as f32 - cx) / a).powi(2) + (((i / w) as f32 - cy) / b).powi(2) < 1.0); + } + "dense" => { + for y in h / 2 - 1000..h / 2 + 1000 { + for x in w / 2 - 1000..w / 2 + 1000 { + m[y * w + x] = hash((y * w + x) as u64) & 1 == 0; + } + } + } + _ => {} + } + m +} + +/// The pyramid levels the completion builds for this hole: the same count formula and the same +/// 2x2 OR of the hole. +fn hole_levels(w: usize, h: usize, hole: &[bool]) -> Vec<(usize, usize, Vec)> { + let n = 2 * R + 1; + let no = hole_radius(w, h, hole).min(1 << 20); + let want = (2.0 * f64::from(no) / n as f64).log2().ceil().max(1.0) as usize; + let (mut lw, mut lh, mut levels) = (w, h, 1); + while levels < want && lw / 2 >= 3 * n && lh / 2 >= 3 * n { + (lw, lh, levels) = (lw.div_ceil(2), lh.div_ceil(2), levels + 1); + } + let mut out = vec![(w, h, hole.to_vec())]; + while out.len() < levels { + let Some((pw, ph, prev)) = out.last() else { break }; + let (nw, nh) = (pw.div_ceil(2), ph.div_ceil(2)); + let next: Vec = (0..nw * nh) + .map(|i| { + let (fx, fy) = ((i % nw) * 2, (i / nw) * 2); + (0..2).any(|dy| (0..2).any(|dx| fx + dx < *pw && fy + dy < *ph && prev[(fy + dy) * pw + fx + dx])) + }) + .collect(); + out.push((nw, nh, next)); + } + out +} + +/// The loop the completion ran per level before the change, verbatim. +fn old_loop(lw: usize, lh: usize, hole: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; lw * lh]; + for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % lw) as i32, (i / lw) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { + targets[ty as usize * lw + tx as usize] = true; + } + } + } + targets +} + +fn main() { + let args: Vec = std::env::args().collect(); + let mode = arg(&args, "--mode").unwrap_or("phase"); + let mp: usize = arg(&args, "--mp").and_then(|v| v.parse().ok()).unwrap_or(24); + let kind = arg(&args, "--hole").unwrap_or("small"); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5); + let large = arg(&args, "--large").and_then(|v| v.split_once('x')).and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?))).unwrap_or((900, 600)); + let (w, h) = if mp == 36 { (7360, 4912) } else { (6000, 4000) }; + let hole = hole_mask(kind, w, h, large); + let line = |rep: usize, variant: &str, ms: f64, hash: u64| { + println!("{{\"mode\":\"{mode}\",\"mp\":{mp},\"hole\":\"{kind}\",\"rep\":{rep},\"variant\":\"{variant}\",\"ms\":{ms:.3},\"hash\":\"{hash:016x}\"}}") + }; + match mode { + "phase" => { + let levels = hole_levels(w, h, &hole); + for rep in 0..reps { + let t = Instant::now(); + let old: Vec> = levels.iter().map(|(lw, lh, m)| old_loop(*lw, *lh, m, R)).collect(); + let old_ms = t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + let new: Vec> = levels.iter().map(|(lw, lh, m)| dilate(*lw, *lh, m, R)).collect(); + let new_ms = t.elapsed().as_secs_f64() * 1e3; + let (ho, hn) = (fnv(old.iter().flatten().map(|b| u8::from(*b))), fnv(new.iter().flatten().map(|b| u8::from(*b)))); + assert_eq!(ho, hn, "the masks differ"); + line(rep, "old_loop", old_ms, ho); + line(rep, "remove_dilate", new_ms, hn); + } + } + "e2e" => { + if kind == "dense" { + eprintln!("the dense hole is a phase-only scenario"); + std::process::exit(2); + } + let img = texture(w, h); + for rep in 0..reps { + let t = Instant::now(); + let Ok(out) = remove_with(w, h, 4, &img, &hole, 1, &Interrupt::NONE) else { + eprintln!("cancelled"); + std::process::exit(1); + }; + let ms = t.elapsed().as_secs_f64() * 1e3; + line(rep, "remove_with", ms, fnv(out.iter().flat_map(|v| v.to_bits().to_le_bytes()))); + } + } + _ => { + eprintln!("unknown --mode {mode}"); + std::process::exit(2); + } + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 5a4d3c008c..ad72954a22 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -78,6 +78,7 @@ mod selection_blur; pub mod separable; mod shape_blur; mod sharpen; +mod square_dilate; pub mod stack; mod stylize; mod stylize2; diff --git a/crates/algo/src/nonlocal.rs b/crates/algo/src/nonlocal.rs index 148a1bd32d..3bc34ceec0 100644 --- a/crates/algo/src/nonlocal.rs +++ b/crates/algo/src/nonlocal.rs @@ -614,7 +614,7 @@ impl Solver<'_> { // `FUSE_BAND` of a seam are solved for. const FUSE_BAND: usize = 16; let seams: Vec = (0..w * h).map(|i| div[i * ch..(i + 1) * ch].iter().any(|v| *v != 0.0)).collect(); - let band = crate::remove::dilate(w, h, &seams, FUSE_BAND); + let band = crate::square_dilate::dilate_square(w, h, &seams, FUSE_BAND); let halves: [Vec; 2] = [0, 1].map(|colour| copied.iter().map(|(p, _, _)| *p).filter(|&p| band[p] && (p % w + p / w) % 2 == colour).collect()); let mut delta = vec![0.0f32; w * h * ch]; let mut next: Vec = Vec::new(); @@ -846,15 +846,7 @@ pub fn complete_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], return Ok(None); } let ri = r as i32; - let mut targets = vec![false; lw * lh]; - for (i, _) in level.hole.iter().enumerate().filter(|(_, m)| **m) { - let (x, y) = ((i % lw) as i32, (i / lw) as i32); - for ty in (y - ri).max(0)..=(y + ri).min(lh as i32 - 1) { - for tx in (x - ri).max(0)..=(x + ri).min(lw as i32 - 1) { - targets[ty as usize * lw + tx as usize] = true; - } - } - } + let targets = crate::square_dilate::dilate_square(lw, lh, &level.hole, r); let mut s = Solver { w: lw, h: lh, cha, ch, r: ri, img: level.img.clone(), hole: &level.hole, valid, valid_list, targets, ctl }; let mut nnf = vec![(0i32, 0i32); lw * lh]; let mut cost = vec![f32::INFINITY; lw * lh]; @@ -1012,4 +1004,20 @@ mod tests { let progress = |_: f32| {}; assert_eq!(complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)), Err(Cancelled)); } + + /// Every cancellation check point stops the completion: moving or dropping one is a change. + #[test] + fn cancelling_at_each_early_check_is_an_error() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (w, h) = (64, 64); + let img: Vec = (0..w * h).map(noise).collect(); + let hole: Vec = (0..w * h).map(|i| (20..40).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + for k in 1..=6 { + let calls = AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, Ordering::Relaxed) + 1 >= k; + let progress = |_: f32| {}; + let got = complete_with(w, h, 1, &img, &hole, &Params::default(), &Interrupt::new(&cancel, &progress)); + assert_eq!(got, Err(Cancelled), "cancelling at check {k}"); + } + } } diff --git a/crates/algo/src/remove.rs b/crates/algo/src/remove.rs index 550bda5809..16a9ae3825 100644 --- a/crates/algo/src/remove.rs +++ b/crates/algo/src/remove.rs @@ -54,34 +54,9 @@ pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec { outside.iter().map(|o| !*o).collect() } -/// `mask` grown by `r` cells (a square structuring element), in two separable passes. +/// `mask` grown by `r` cells (a square structuring element, clipped to the grid). pub fn dilate(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { - if r == 0 || mask.len() != w.saturating_mul(h) { - return mask.to_vec(); - } - // A pass sets each cell when any cell within `r` along one axis is set (a running count). - let pass = |src: &[bool], horizontal: bool| -> Vec { - let (lines, len) = if horizontal { (h, w) } else { (w, h) }; - let at = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line }; - let mut out = vec![false; src.len()]; - for line in 0..lines { - let mut count = 0usize; - for k in 0..len.min(r) { - count += usize::from(src[at(line, k)]); - } - for k in 0..len { - if k + r < len { - count += usize::from(src[at(line, k + r)]); - } - if k > r { - count -= usize::from(src[at(line, k - r - 1)]); - } - out[at(line, k)] = count > 0; - } - } - out - }; - pass(&pass(mask, true), false) + crate::square_dilate::dilate_square(w, h, mask, r) } /// The largest chessboard distance from a hole cell to the nearest cell outside the hole (cells diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index 4dcf0b6944..a0c363732a 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -288,6 +288,21 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect { Rect::new((r.x0 as f64 * sx).floor() as i32, (r.y0 as f64 * sy).floor() as i32, (r.x1 as f64 * sx).ceil() as i32, (r.y1 as f64 * sy).ceil() as i32) } +/// The largest rectangle a resize of content at `r` by `(sx, sy)` with `filter` stores: the +/// result, or the image between the passes of a two-pass bicubic. That one keeps an axis at its +/// old size, so when both axes shrink it is larger than the result (#9212). +pub fn largest_resize_rect(r: Rect, sx: f64, sy: f64, filter: Resample) -> Rect { + let area = |r: Rect| u64::from(r.width()) * u64::from(r.height()); + let (mut cur, mut largest) = (r, Rect::EMPTY); + for p in plan(filter, sx, sy) { + cur = scaled_rect(cur, p.sx, p.sy); + if area(cur) > area(largest) { + largest = cur; + } + } + largest +} + /// Resizes a surface by `(sx, sy)` about the document origin. The surface's /// default pixel is kept (masks keep their "reveal all" background). pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface { @@ -346,12 +361,35 @@ fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option) /// Runs `filter`'s passes. Between two passes the image is stored in its own format, which /// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so -/// the intermediate image is never larger than the result. +/// the intermediate image is never larger than the result when an axis grows, but is when both +/// shrink ([`largest_resize_rect`]). fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize) -> Surface { + resize_controlled(s, sx, sy, filter, canvas, budget, &photocraft_raster::Interrupt::NONE) +} + +/// Resize with cancellation between passes and bounded groups of output bands. +/// A cancelled operation returns no partially resized surface. +pub fn resize_surface_with_interrupt( + s: &Surface, + sx: f64, + sy: f64, + filter: Resample, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let out = resize_controlled(s, sx, sy, filter, None, READ_BUDGET, ctl); + ctl.check()?; + Ok(out) +} + +fn resize_controlled(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { let mut cur: Option = None; let mut canvas = canvas; for p in plan(filter, sx, sy) { - let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget); + if ctl.cancelled() { + return Surface::new(s.format()); + } + let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget, ctl); canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy)); cur = Some(next); } @@ -368,7 +406,7 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: O /// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see /// [`resize_surface_in_canvas`]). -fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> Surface { +fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface { let Pass { sx, sy, kx, ky } = pass; let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty()); let fmt = s.format(); @@ -484,6 +522,9 @@ fn resize_pass(s: &Surface, pass: Pass, canvas: Option, budget: usize) -> #[cfg(target_arch = "wasm32")] let group = 1; for chunk in bands.chunks(group) { + if ctl.cancelled() { + return Surface::new(fmt); + } #[cfg(not(target_arch = "wasm32"))] let parts: Vec<(Rect, Vec)> = { use rayon::prelude::*; diff --git a/crates/algo/src/resample/tests.rs b/crates/algo/src/resample/tests.rs index 0ee08847e1..2407457a2d 100644 --- a/crates/algo/src/resample/tests.rs +++ b/crates/algo/src/resample/tests.rs @@ -400,3 +400,37 @@ fn nearest_neighbor_reduction_does_not_average() { } } } + +/// #9212: a bicubic shrink of both axes stores the image between its passes with one axis at the +/// old size, larger than the result; single-pass filters and growing axes store nothing larger. +#[test] +fn largest_resize_rect_counts_the_image_between_the_passes() { + let r = Rect::new(0, 0, 100, 80); + for f in [Resample::Bicubic, Resample::BicubicSharper, Resample::BicubicAutomatic, Resample::PreserveDetails] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 80), "{f:?}"); + } + // The axis with the smaller scale goes first. + assert_eq!(largest_resize_rect(r, 0.5, 0.25, Resample::Bicubic), Rect::new(0, 0, 100, 20)); + for f in [Resample::Bilinear, Resample::Nearest, Resample::Lanczos] { + assert_eq!(largest_resize_rect(r, 0.5, 0.5, f), Rect::new(0, 0, 50, 40), "{f:?}"); + } + for f in [Resample::Bicubic, Resample::BicubicAutomatic] { + assert_eq!(largest_resize_rect(r, 2.0, 3.0, f), Rect::new(0, 0, 200, 240), "{f:?} grows"); + assert_eq!(largest_resize_rect(r, 0.5, 2.0, f), Rect::new(0, 0, 50, 160), "{f:?} shrinks x, grows y"); + } +} + +#[test] +fn controlled_resize_matches_pixels_and_discards_cancelled_work() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let src = ramp(depth); + let expected = resize_surface(&src, 1.7, 0.6, Resample::Bicubic); + let actual = resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &photocraft_raster::Interrupt::NONE).unwrap(); + let bounds = expected.content_bounds(); + assert_eq!(expected.read_region(bounds), actual.read_region(bounds)); + let cancelled = || true; + let progress = |_| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + assert!(resize_surface_with_interrupt(&src, 1.7, 0.6, Resample::Bicubic, &ctl).is_err()); + } +} diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index 75a1a47a56..8db2f1517d 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -13,20 +13,25 @@ //! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the //! lightly blurred CIELAB image from its mean colour. //! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels -//! are definite background); the result is cleaned (small islands dropped). +//! are definite background); the result is cleaned: small islands are dropped unless they lie +//! next to the main part (a head cut off by a neck too thin for the working resolution). //! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted //! to the image's frame marks every pixel it explains as probable background, and the rest //! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground. //! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white //! wall) have low saliency and in step 4 land in the background model, which then explains -//! them away; here they start as foreground and the cut keeps them (#2788). Only what -//! touches the first pass's result survives, so the pass cannot pull in unrelated things. +//! them away; here they start as foreground and the cut keeps them (#2788). Only components +//! touching the first pass's result survive, but attached clutter can survive with them. +//! Where the subject runs out of the picture (a torso cut off by the bottom edge), the frame +//! pixels the rest of the frame cannot explain are left out of the backdrop model and left to +//! the cut; otherwise they would teach it the shirt's colour and be fixed as background. //! 6. Holes are filled only when their colour fits the foreground model better than the //! background's, so a patch of backdrop enclosed by the subject (between an arm and the //! torso) stays open; specks still fill. Then the mask is re-cut at full resolution. //! //! Works well for a distinct object on a comparatively plain background; it is not a semantic -//! segmenter. +//! segmenter. Nearby clutter and detached subject parts with the same geometry cannot be +//! distinguished by the component filter. use std::cmp::Ordering; use std::collections::BinaryHeap; @@ -82,7 +87,7 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) } 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.0); + let low = keep_main_and_nearby(&low, w, h); if !low.iter().any(|v| *v) { return Ok(None); } @@ -94,39 +99,27 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) /// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model /// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the -/// subject runs out of the picture); the frame is definite background, every other pixel it +/// subject runs out of the picture, and minus what [`subject_at_edge`] finds of the subject +/// there); the rest of the frame is definite background, every other pixel it /// explains well is probable background, and the rest (the blob, and anything the backdrop /// cannot account for, such as a cream shirt on a near-white wall) starts as probable /// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that /// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass. fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result, Gmm, Gmm)>, Cancelled> { let (w, h) = (img.w, img.h); - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); - for (i, on) in blob.iter().enumerate() { - if *on { - let (x, y) = (i % w, i / w); - (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); - } - } - if x1 < x0 || y1 < y0 { - return Ok(None); - } - let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); - let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin)); - let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1)); + let Some(near) = margin_box(blob, w, h) else { return Ok(None) }; + let (bx0, by0, bx1, by1) = near; let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2); let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h; let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y); - let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect(); - let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) }; - // What the frame itself costs under its own model bounds "explained well". - let mut frame_costs: Vec = samples.iter().map(|p| backdrop.neg_log(*p)).collect(); - frame_costs.sort_by(|a, b| a.total_cmp(b)); - let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) }; + let edge = |i: usize| in_frame(i % w, i / w) && !near_blob(i % w, i / w); + // The subject running out of the picture is neither backdrop to learn nor fixed background. + let released = subject_at_edge(img, blob, frame, near, &edge); + let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !released[*i]).map(|i| img.px[i]).collect(); + let Some((backdrop, limit)) = fit_backdrop(&samples) else { return Ok(None) }; let mut trimap: Vec = (0..w * h) .map(|i| { - let (x, y) = (i % w, i / w); - if in_frame(x, y) && !near_blob(x, y) { + if edge(i) && !released[i] { BG } else if blob[i] || backdrop.neg_log(img.px[i]) > limit { PR_FG @@ -141,6 +134,359 @@ fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Resul Ok(low.iter().any(|v| *v).then_some((low, fg, bg))) } +/// The box around the pixels `blob` marks in a `w` × `h` image, grown on every side by 15% of +/// its longer side (at least four pixels) and clipped to the image, as (x0, y0, x1, y1) +/// inclusive. `None` when `blob` marks nothing. +fn margin_box(blob: &[bool], w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, on) in blob.iter().enumerate() { + if *on { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + if x1 < x0 || y1 < y0 { + return None; + } + let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); + Some((x0.saturating_sub(margin), y0.saturating_sub(margin), (x1 + margin).min(w - 1), (y1 + margin).min(h - 1))) +} + +/// The connected parts of `mask` that make up the subject: those at least a fifth the size of +/// the largest, and smaller ones (not specks) with pixels close to it. A part of the subject the +/// cut separated from the rest, such as a head whose thin neck the working resolution lost, a +/// tail or something held, would otherwise go missing; clutter further away is still dropped. +fn keep_main_and_nearby(mask: &[bool], w: usize, h: usize) -> Vec { + let (lab, sizes) = super::components(mask, w, h); + let Some((main, largest)) = sizes.iter().copied().enumerate().skip(1).max_by_key(|(_, n)| *n) else { return vec![false; w * h] }; + // Bounding boxes per component: (x0, y0, x1, y1). + let mut bbox = vec![(usize::MAX, usize::MAX, 0usize, 0usize); sizes.len()]; + for (i, l) in lab.iter().enumerate() { + if let Some(b) = bbox.get_mut(*l as usize) { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + let Some(&(mx0, my0, mx1, my1)) = bbox.get(main) else { return vec![false; w * h] }; + let margin = ((mx1 - mx0 + 1).max(my1 - my0 + 1) as f32 * 0.15).ceil() as usize; + let (nx0, ny0, nx1, ny1) = (mx0.saturating_sub(margin), my0.saturating_sub(margin), mx1 + margin, my1 + margin); + // A part is near when some of its pixels lie within the margin, not merely its box: a thin + // diagonal streak from a corner (a cable, a rail) can have a box that reaches the margin. + let mut near = vec![false; sizes.len()]; + for y in ny0..=ny1.min(h - 1) { + for x in nx0..=nx1.min(w - 1) { + let label = lab.get(y * w + x).copied().unwrap_or(0) as usize; + if let Some(n) = near.get_mut(label) { + *n = true; + } + } + } + let (big, speck) = ((largest as f32 * 0.2).ceil() as usize, ((largest as f32 * 0.005).ceil() as usize).max(4)); + // Only small, nearby components can be vetoed as edge clutter. A strip must be + // narrow relative to the canvas and at least twice as long as it is wide. + let keep: Vec = sizes + .iter() + .zip(&bbox) + .enumerate() + .map(|(label, (&size, &(x0, y0, x1, y1)))| { + if label == 0 { + return false; + } + if label == main || size >= big { + return true; + } + let (width, height) = (x1 - x0 + 1, y1 - y0 + 1); + let nearby = near.get(label).copied().unwrap_or(false); + let horizontal_strip = (x0 == 0 || x1 + 1 == w) && height < h * 12 / 100 && width >= height * 2; + let vertical_strip = (y0 == 0 || y1 + 1 == h) && width < w * 12 / 100 && height >= width * 2; + let thin_edge = horizontal_strip || vertical_strip; + size >= speck && nearby && !thin_edge + }) + .collect(); + lab.iter().map(|l| keep.get(*l as usize).copied().unwrap_or(false)).collect() +} + +#[cfg(test)] +mod component_tests { + use super::{keep_main_and_nearby, margin_box, subject_at_edge}; + use crate::segment::RgbImage; + + #[test] + fn nearby_component_margin_and_size_boundaries() { + // The main 20x20 component has margin ceil(20 * .15) = 3 and a four-pixel floor. + // A 30x30 component raises the floor to ceil(900 * .005) = 5. + for (side, x, count, retained) in [(20, 42, 3, false), (20, 42, 4, true), (20, 43, 4, false), (30, 54, 4, false), (30, 54, 5, true), (30, 55, 5, false)] + { + let (w, h) = (80, 80); + let mut mask = vec![false; w * h]; + for y in 20..20 + side { + for x in 20..20 + side { + mask[y * w + x] = true; + } + } + for y in 25..25 + count { + mask[y * w + x] = true; + } + let result = keep_main_and_nearby(&mask, w, h); + assert!(result[20 * w + 20]); + assert_eq!(result[25 * w + x], retained, "side {side}, x {x}, size {count}"); + } + // A qualifying nearby island is kept whether it is a head or unrelated clutter. + } + + fn rotate(x: usize, y: usize, side: usize, turns: usize) -> (usize, usize) { + let (mut x, mut y) = (x, y); + for _ in 0..turns { + (x, y) = (side - 1 - y, x); + } + (x, y) + } + + #[test] + fn cropped_heads_and_large_limbs_survive_on_every_edge() { + let side = 240; + for turns in 0..4 { + for (main, part) in [((100, 20, 140, 70), (111, 0, 129, 18)), ((80, 70, 120, 120), (122, 90, 240, 100))] { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [main, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(part.0, part.1, side, turns); + assert!(result[y * side + x], "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn small_nearby_elongated_edge_strips_are_excluded() { + let side = 240; + for turns in 0..4 { + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(70, 80, 170, 180), (112, 0, 120, 70)] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + let (x, y) = rotate(112, 0, side, turns); + assert!(!result[y * side + x], "edge {turns}"); + } + } + + #[test] + fn narrow_edge_parts_matching_subject_are_released_on_every_edge() { + let side = 240; + let frame = 12; + for turns in 0..4 { + for (colour, expected) in [([0.8, 0.2, 0.1], true), ([0.1, 0.2, 0.8], false)] { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for y in 60..100 { + for x in 100..140 { + let (x, y) = rotate(x, y, side, turns); + blob[y * side + x] = true; + img.px[y * side + x] = [0.8, 0.2, 0.1]; + } + } + for y in 0..frame { + for x in 110..120 { + let (x, y) = rotate(x, y, side, turns); + img.px[y * side + x] = colour; + } + } + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + x < frame || y < frame || x + frame >= side || y + frame >= side + }; + let near = margin_box(&blob, side, side).unwrap(); + let released = subject_at_edge(&img, &blob, frame, near, &edge); + let (x, y) = rotate(110, 0, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, colour {colour:?}"); + } + } + } + + #[test] + fn wide_edge_parts_are_released_only_alongside_the_subject() { + // A dark pole along the left edge, next to the shoulder of a figure that runs off the + // bottom edge, stays backdrop although it runs a long way along that edge. A cream torso + // cut off by the bottom edge below a head (the first pass lost it, so the subject's + // colours do not explain it either) is still released. + let side = 240; + let frame = 12; + // (subject, part, part colour, released), boxes as (x0, y0, x1, y1) exclusive. + let cases = [((10, 100, 110, 240), (0, 0, 8, 240), [0.1, 0.1, 0.1], false), ((100, 40, 140, 80), (60, 228, 180, 240), [0.9, 0.85, 0.7], true)]; + for turns in 0..4 { + for (subject, part, colour, expected) in cases { + let mut img = RgbImage::new(side, side); + img.px.fill([0.5, 0.5, 0.5]); + let mut blob = vec![false; side * side]; + for (x0, y0, x1, y1) in [subject, part] { + for y in y0..y1 { + for x in x0..x1 { + let (x, y) = rotate(x, y, side, turns); + let on = (x0, y0) == (subject.0, subject.1); + blob[y * side + x] = on; + img.px[y * side + x] = if on { [0.8, 0.2, 0.1] } else { colour }; + } + } + } + // As in `backdrop_pass`: the frame, minus the blob's box. + let (bx0, by0, bx1, by1) = margin_box(&blob, side, side).unwrap(); + let edge = |i: usize| { + let (x, y) = (i % side, i / side); + let in_frame = x < frame || y < frame || x + frame >= side || y + frame >= side; + in_frame && !((bx0..=bx1).contains(&x) && (by0..=by1).contains(&y)) + }; + let released = subject_at_edge(&img, &blob, frame, (bx0, by0, bx1, by1), &edge); + let (x, y) = rotate(part.0, part.1, side, turns); + assert_eq!(released[y * side + x], expected, "edge {turns}, part {part:?}"); + } + } + } + + #[test] + fn nearby_means_pixels_within_the_margin_not_just_the_box() { + // The main part covers 100..140 × 20..60, so its margin box is 94..=145 × 14..=65. A + // one-pixel diagonal streak from the corner (a cable, a rail) to (100, 100) has a box + // that overlaps the margin box, but none of its pixels lie in it: it is not kept. A + // compact part whose pixels lie inside the margin is. + let side = 240; + let mut mask = vec![false; side * side]; + for y in 20..60 { + for x in 100..140 { + mask[y * side + x] = true; + } + } + for k in 0..=100 { + mask[k * side + k] = true; + } + for y in 30..35 { + for x in 142..146 { + mask[y * side + x] = true; + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[20 * side + 100]); + assert!(!result[0], "the corner of the streak is kept"); + assert!(!result[100 * side + 100], "the end of the streak is kept"); + assert!(result[30 * side + 142], "the compact part inside the margin is dropped"); + } + + #[test] + fn clutter_away_from_the_subject_or_too_small_is_dropped() { + // The main 40x40 part has margin ceil(40 * .15) = 6 and a speck floor of + // ceil(1600 * .005) = 8 pixels. A 5x5 object just past the margin and a 2x3 one inside + // it are clutter; a 3x3 one inside it is kept. + let side = 240; + let mut mask = vec![false; side * side]; + for (x0, y0, x1, y1) in [(100, 100, 140, 140), (147, 110, 152, 115), (110, 143, 112, 146), (120, 143, 123, 146)] { + for y in y0..y1 { + for x in x0..x1 { + mask[y * side + x] = true; + } + } + } + let result = keep_main_and_nearby(&mask, side, side); + assert!(result[100 * side + 100]); + assert!(!result[110 * side + 147], "clutter past the margin is kept"); + assert!(!result[143 * side + 110], "a speck inside the margin is kept"); + assert!(result[143 * side + 120], "a part inside the margin is dropped"); + } +} + +/// A colour model of `samples` and the cost above which a pixel is not explained by it: what +/// the samples themselves cost under it (99th percentile), plus a margin. `None` without samples. +fn fit_backdrop(samples: &[[f32; 3]]) -> Option<(Gmm, f32)> { + let model = Gmm::fit(&super::subsample(samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG)?; + let mut costs: Vec = samples.iter().map(|p| model.neg_log(*p)).collect(); + costs.sort_by(|a, b| a.total_cmp(b)); + let limit = costs.get(costs.len() * 99 / 100)? + 4.0; + Some((model, limit)) +} + +/// The pixels of the frame (those `edge` accepts, `frame` wide) that belong to the subject +/// running out of the picture, such as a torso cut off by the bottom edge: pixels of a side +/// (its corners included) that the colours of the frame off that side cannot explain. Without +/// this, such a part trains the backdrop model and is fixed as background, so a cream shirt on a +/// near-white wall is lost. A floor or a table runs up the sides too, so the frame off the +/// bottom knows its colour and it stays backdrop; only a band confined to one side's strip is +/// released (and is then left to the cut). Small patches stay backdrop. Narrow patches are +/// released only if their colour is also explained by the retained subject; wide ones whatever +/// their colour, but only where they run alongside `near` (the blob's box with its margin, as +/// [`margin_box`] gives it): a pole or a door frame along a side next to the subject's shoulder +/// stays backdrop, while a torso the first pass lost still lies below the head. +fn subject_at_edge(img: &super::RgbImage, blob: &[bool], frame: usize, near: (usize, usize, usize, usize), edge: &dyn Fn(usize) -> bool) -> Vec { + let (w, h) = (img.w, img.h); + // Top, bottom, left, right. + let on = |i: usize, s: usize| { + let (x, y) = (i % w, i / w); + [y < frame, y + frame >= h, x < frame, x + frame >= w].get(s).copied().unwrap_or(false) + }; + let subject_samples: Vec<[f32; 3]> = img.px.iter().zip(blob).filter_map(|(pixel, on)| on.then_some(*pixel)).collect(); + let subject_model = fit_backdrop(&subject_samples); + let mut odd = vec![false; w * h]; + for s in 0..4 { + let others: Vec<[f32; 3]> = (0..w * h).filter(|i| edge(*i) && !on(*i, s)).map(|i| img.px[i]).collect(); + let Some((model, limit)) = fit_backdrop(&others) else { continue }; + for (i, o) in odd.iter_mut().enumerate() { + if !*o && edge(i) && on(i, s) && model.neg_log(img.px[i]) > limit { + *o = true; + } + } + } + // A stray pixel (noise, a dead pixel) is not a subject: only patches of some size count. + let (lab, sizes) = super::components(&odd, w, h); + let min = (frame * frame / 2).max(4); + let mut bounds = vec![(w, h, 0usize, 0usize); sizes.len()]; + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && let Some(b) = bounds.get_mut(label as usize) + { + let (x, y) = (i % w, i / w); + *b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + // Count matching pixels per component so a single coincidental pixel cannot release it. + let mut matching = vec![0usize; sizes.len()]; + if let Some((model, limit)) = subject_model { + for (i, &label) in lab.iter().enumerate() { + if label != 0 + && model.neg_log(img.px[i]) <= limit + && let Some(count) = matching.get_mut(label as usize) + { + *count += 1; + } + } + } + lab.iter() + .map(|l| { + let i = *l as usize; + let Some((&size, &(x0, y0, x1, y1))) = sizes.get(i).zip(bounds.get(i)) else { return false }; + if *l == 0 || size < min { + return false; + } + // Along the edge, a wide patch must overlap the blob's box: beside the subject, `edge` + // leaves out that box, so a pole along a side shows up only above or below it. + let (nx0, ny0, nx1, ny1) = near; + let wide_on_horizontal_edge = (y0 == 0 || y1 + 1 == h) && x1 - x0 + 1 >= 2 * frame && x0 <= nx1 && x1 >= nx0; + let wide_on_vertical_edge = (x0 == 0 || x1 + 1 == w) && y1 - y0 + 1 >= 2 * frame && y0 <= ny1 && y1 >= ny0; + let matches_subject = matching.get(i).is_some_and(|count| *count >= size.div_ceil(2)); + wide_on_horizontal_edge || wide_on_vertical_edge || matches_subject + }) + .collect() +} + /// Per-pixel saliency in `0..=1` (see the module docs). pub fn saliency(img: &super::RgbImage) -> Vec { let (w, h) = (img.w, img.h); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 07f52b815b..6a36bb53cd 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -962,6 +962,148 @@ fn select_subject_leaves_a_background_coloured_gap_open() { assert!(v > 0.9, "IoU {v}"); } +/// A portrait on a light wall shading down the picture: dark hair, a face, a neck and a +/// torso of colour `shirt` running off the bottom edge (as most portraits are cropped). With +/// `floor`, the torso ends on a dark table that fills the bottom quarter, the full width (so it +/// runs up the picture's sides too). Returns (image, truth, the torso). +fn portrait_scene(seed: u64, shirt: [f32; 3], floor: bool) -> (RgbImage, Vec, Vec) { + let (w, h) = (240usize, 200usize); + let mut rng = Rng::new(seed); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut torso = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let hair = ((fx - 120.0) / 30.0).powi(2) + ((fy - 52.0) / 27.0).powi(2) <= 1.0; + let face = ((fx - 120.0) / 26.0).powi(2) + ((fy - 66.0) / 32.0).powi(2) <= 1.0; + let neck = (109..131).contains(&x) && (90..112).contains(&y); + // Shoulders widening towards the bottom, wider than the head's box. + let half = 40.0 + (fy - 110.0) * 0.8; + let body = y >= 110 && (fx - 120.0).abs() <= half && (!floor || y < 150); + let table = floor && y >= 150; + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = hair || face || neck || body; + torso[i] = body && !neck; + let wall = 0.95 - 0.07 * fy / h as f32; + img.px[i] = if table { + [0.35, 0.22, 0.12] + } else if face { + [0.88, 0.71, 0.59] + } else if hair { + [0.24, 0.16, 0.12] + } else if neck { + [0.84, 0.67, 0.55] + } else if body { + shirt + } else { + [wall, wall - 0.01, wall - 0.04] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + (img, truth, torso) +} + +#[test] +fn select_subject_keeps_a_low_contrast_part_that_runs_off_the_edge() { + // A cream shirt on a near-white wall, cut off by the bottom edge: it used to train the + // backdrop model and be fixed as background there, so only the head was selected. + let (img, truth, torso) = portrait_scene(3, [0.92, 0.85, 0.72], false); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let c = coverage(®, &torso, 240, 200); + assert!(c > 0.9, "the torso is part of the subject: covered {c}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_table_along_the_bottom_edge_out() { + // The same figure behind a table that fills the bottom of the picture: the table also runs + // off the edge, but it is not the subject. + let (img, truth, _) = portrait_scene(3, [0.12, 0.2, 0.45], true); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let table: Vec = (0..240 * 200).map(|i| i / 240 >= 150).collect(); + let t = coverage(®, &table, 240, 200); + assert!(t < 0.1, "the table stays unselected: covered {t}"); + let v = iou(®, &truth, 240, 200); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_nearby_single_edge_object_out() { + // A horizontal door-frame piece enters only from the left and stops just short of the + // shoulder. It is large enough to be considered by the edge release heuristic. + let (mut img, truth, _) = low_contrast_figure(7); + let (w, h) = (240, 200); + let mut frame = vec![false; w * h]; + for y in 48..56 { + for x in 0..77 { + frame[y * w + x] = true; + img.px[y * w + x] = [0.32, 0.24, 0.16]; + } + } + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let subject_coverage = coverage(®, &truth, w, h); + let frame_coverage = coverage(®, &frame, w, h); + assert!(subject_coverage > 0.85, "subject coverage {subject_coverage}"); + assert!(frame_coverage < 0.1, "door-frame coverage {frame_coverage}"); +} + +#[test] +fn select_subject_ignores_a_stray_pixel_at_the_edge() { + // One white pixel in the corner (a dead pixel, a speck of dust) is not the subject running + // out of the picture: it stays unselected and the subject is found as without it. + let canvas = Rect::new(0, 0, 200, 160); + for seed in 1..=8 { + let (mut img, truth) = disc_scene(200, 160, 100.0, 80.0, 45.0, seed); + img.px[0] = [1.0, 1.0, 1.0]; + let reg = subject::select_subject(&ImageSampler { img: &img, origin: (0, 0) }, canvas).unwrap(); + assert!(reg.at(0, 0) < 0.5, "seed {seed}: the stray pixel is selected"); + let v = iou(®, &truth, 200, 160); + assert!(v > 0.9, "seed {seed}: IoU {v}"); + } +} + +#[test] +fn select_subject_keeps_a_small_detached_part_next_to_the_subject() { + // A standing figure whose head is apart from its body (the neck too thin to survive the + // working resolution): the head is under a fifth of the body's size but part of the subject. + let (w, h) = (300usize, 200usize); + let mut rng = Rng::new(9); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut head = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32, y as f32); + let is_head = ((fx - 150.0) / 9.0).powi(2) + ((fy - 40.0) / 11.0).powi(2) <= 1.0; + let body = (128..172).contains(&x) && (55..170).contains(&y); + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = is_head || body; + head[i] = is_head; + img.px[i] = if is_head { + [0.88, 0.7, 0.58] + } else if body { + [0.75, 0.15, 0.15] + } else { + [0.55, 0.72, 0.9] + } + .map(|v| (v + n).clamp(0.0, 1.0)); + } + } + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let c = coverage(®, &head, w, h); + assert!(c > 0.9, "the head is part of the subject: covered {c}"); + let v = iou(®, &truth, w, h); + assert!(v > 0.9, "IoU {v}"); +} + #[test] fn antialias_blends_edge_pixels_with_their_neighbourhood() { // A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth; diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index c31a883a19..8493f2c1e8 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -941,49 +941,79 @@ pub struct ColorMatcher { } #[derive(Clone, Debug)] -enum ColorBvh { - Empty, - Leaf(Vec<[f32; 4]>), - Node { min: [f32; 4], max: [f32; 4], left: Box, right: Box }, +struct ColorBvh { + points: Vec<[f32; 4]>, + nodes: Vec, +} + +#[derive(Clone, Debug)] +enum ColorNode { + Leaf { start: usize, end: usize }, + Branch { min: [f32; 4], max: [f32; 4], left: usize, right: usize }, } impl ColorBvh { - fn build(mut points: Vec<[f32; 4]>) -> Self { - if points.is_empty() { - return ColorBvh::Empty; + fn build(mut points: Vec<[f32; 4]>) -> Result { + let capacity = Self::node_count(points.len()); + let mut nodes = Self::reserve_nodes(capacity)?; + if !points.is_empty() { + Self::build_nodes(&mut points, 0, &mut nodes); } + Ok(Self { points, nodes }) + } + + fn node_count(len: usize) -> usize { + if len == 0 { + 0 + } else if len <= 16 { + 1 + } else { + 1 + Self::node_count(len / 2) + Self::node_count(len - len / 2) + } + } + + fn reserve_nodes(capacity: usize) -> Result, &'static str> { + let mut nodes = Vec::new(); + nodes.try_reserve_exact(capacity).map_err(|_| "not enough memory to index selection colours")?; + Ok(nodes) + } + + // Reserve the exact node count before recursion so pushes cannot allocate. + fn build_nodes(points: &mut [[f32; 4]], start: usize, nodes: &mut Vec) -> usize { if points.len() <= 16 { - return ColorBvh::Leaf(points); + let index = nodes.len(); + nodes.push(ColorNode::Leaf { start, end: start + points.len() }); + return index; } let (mut min, mut max) = ([f32::MAX; 4], [f32::MIN; 4]); - for p in &points { + for p in points.iter() { for c in 0..4 { min[c] = min[c].min(p[c]); max[c] = max[c].max(p[c]); } } - let mut best_axis = 0; - let mut max_spread = -1.0; - for c in 0..4 { - let spread = max[c] - min[c]; - if spread > max_spread { - max_spread = spread; - best_axis = c; + let mut axis = 0; + for c in 1..4 { + if max[c] - min[c] > max[axis] - min[axis] { + axis = c; } } let mid = points.len() / 2; - points.select_nth_unstable_by(mid, |a, b| a[best_axis].total_cmp(&b[best_axis])); - let right_pts = points.split_off(mid); - let left = ColorBvh::build(points); - let right = ColorBvh::build(right_pts); - ColorBvh::Node { min, max, left: Box::new(left), right: Box::new(right) } + points.select_nth_unstable_by(mid, |a, b| a[axis].total_cmp(&b[axis])); + let (left_points, right_points) = points.split_at_mut(mid); + let left = Self::build_nodes(left_points, start, nodes); + let right = Self::build_nodes(right_points, start + mid, nodes); + let index = nodes.len(); + nodes.push(ColorNode::Branch { min, max, left, right }); + index } - fn matches(&self, q: &[f32; 4], tol: f32) -> bool { - match self { - ColorBvh::Empty => false, - ColorBvh::Leaf(pts) => pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6)), - ColorBvh::Node { min, max, left, right } => { + fn matches_node(&self, index: usize, q: &[f32; 4], tol: f32) -> bool { + match self.nodes.get(index) { + Some(ColorNode::Leaf { start, end }) => { + self.points.get(*start..*end).is_some_and(|pts| pts.iter().any(|p| (0..4).all(|c| (q[c] - p[c]).abs() <= tol + 1e-6))) + } + Some(ColorNode::Branch { min, max, left, right }) => { for c in 0..4 { let d = if q[c] < min[c] { min[c] - q[c] @@ -996,15 +1026,39 @@ impl ColorBvh { return false; } } - left.matches(q, tol) || right.matches(q, tol) + self.matches_node(*left, q, tol) || self.matches_node(*right, q, tol) } + None => false, } } + + fn matches(&self, q: &[f32; 4], tol: f32) -> bool { + self.nodes.len().checked_sub(1).is_some_and(|root| self.matches_node(root, q, tol)) + } } +/// Bytes of memory the index is charged per distinct colour: the dedupe set entry and its +/// table slack, the colour in the point list, and its share of the tree nodes. It is an +/// approximate average, not a worst case: while the set rehashes or the point list doubles, +/// the peak runs to roughly 70–90 bytes per colour. +const COLOR_INDEX_BYTES: usize = 64; + +/// The most memory the index may be charged. It scales with the distinct colours rather than the +/// canvas, so a noisy 24 MP photo, or a float document where every pixel is its own colour, +/// still builds; it only keeps the index within the sampled pixels' own budget. On 32-bit +/// (the web build) 1 GiB still admits every distinct colour of a 24 MP photo. +const COLOR_INDEX_BUDGET: usize = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + impl ColorMatcher { /// Creates a color matcher from selected RGBA colors and an $L_\\infty$ tolerance in $[0, 1]$. - pub fn new(selected_colors: impl IntoIterator, tol: f32) -> Self { + pub fn new(selected_colors: impl IntoIterator, tol: f32) -> Result { + Self::with_budget(selected_colors, tol, COLOR_INDEX_BUDGET) + } + + // Bound the index by `budget` bytes, charged at `COLOR_INDEX_BYTES` per distinct colour, and + // fail without altering the selection. + fn with_budget(selected_colors: impl IntoIterator, tol: f32, budget: usize) -> Result { + let limit = budget / COLOR_INDEX_BYTES; let mut has_transparent = false; let mut seen = std::collections::HashSet::new(); let mut opaque_or_semi = Vec::new(); @@ -1014,22 +1068,31 @@ impl ColorMatcher { has_transparent |= c[3] == 0.0; let norm = |x: f32| if x == 0.0 { 0.0f32.to_bits() } else { x.to_bits() }; let key = [norm(c[0]), norm(c[1]), norm(c[2]), norm(c[3])]; - if seen.insert(key) { + if !seen.contains(&key) { + if seen.len() >= limit { + return Err("the selection contains too many distinct colours to match; select a smaller area"); + } + seen.try_reserve(1).map_err(|_| "not enough memory to index selection colours")?; + opaque_or_semi.try_reserve(1).map_err(|_| "not enough memory to index selection colours")?; + seen.insert(key); opaque_or_semi.push(c); } } + drop(seen); opaque_or_semi.sort_unstable_by(|a, b| { a[0].total_cmp(&b[0]).then_with(|| a[1].total_cmp(&b[1])).then_with(|| a[2].total_cmp(&b[2])).then_with(|| a[3].total_cmp(&b[3])) }); opaque_or_semi.dedup_by(|a, b| a == b); + // The tree keeps this list; give back the slack its doubling left. + opaque_or_semi.shrink_to_fit(); - Self { has_transparent, tree: ColorBvh::build(opaque_or_semi), tol: tol.max(0.0) } + Ok(Self { has_transparent, tree: ColorBvh::build(opaque_or_semi)?, tol: tol.max(0.0) }) } /// Returns `true` if no colors (including transparent pixels) were provided. #[inline] pub fn is_empty(&self) -> bool { - !self.has_transparent && matches!(self.tree, ColorBvh::Empty) + !self.has_transparent && self.tree.nodes.is_empty() } /// Returns `true` if `q` falls within `tol` of any selected color. diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 6976776af1..4e7bdcbe06 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -2,7 +2,7 @@ fn color_matcher_bvh_and_transparency() { let red = [1.0, 0.0, 0.0, 1.0]; let blue = [0.0, 0.0, 1.0, 1.0]; - let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0); + let matcher = ColorMatcher::new([red, blue], 10.0 / 255.0).unwrap(); assert!(!matcher.is_empty()); assert!(matcher.matches([0.98, 0.02, 0.0, 1.0])); @@ -14,13 +14,13 @@ fn color_matcher_bvh_and_transparency() { assert!(!matcher.matches([0.5, 0.0, 0.5, 1.0])); assert!(!matcher.matches([0.0, 0.0, 0.0, 0.0])); - let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0); + let matcher_trans = ColorMatcher::new([red, [0.0, 0.0, 0.0, 0.0]], 10.0 / 255.0).unwrap(); assert!(matcher_trans.matches([0.0, 0.0, 0.0, 0.0])); assert!(matcher_trans.matches([0.5, 0.2, 0.1, 0.0])); assert!(matcher_trans.matches([0.98, 0.0, 0.0, 1.0])); assert!(!matcher_trans.matches([0.0, 1.0, 0.0, 1.0])); - let empty = ColorMatcher::new([], 10.0 / 255.0); + let empty = ColorMatcher::new([], 10.0 / 255.0).unwrap(); assert!(empty.is_empty()); assert!(!empty.matches(red)); @@ -28,7 +28,7 @@ fn color_matcher_bvh_and_transparency() { let faint = [0.0, 0.0, 0.0, 1e-6]; assert!(!matcher_trans.matches([0.5, 0.2, 0.1, 1e-6])); assert!(matcher_trans.matches(faint)); - let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0); + let matcher_faint = ColorMatcher::new([faint], 10.0 / 255.0).unwrap(); assert!(matcher_faint.matches([0.0, 0.0, 0.0, 0.0])); assert!(!matcher_faint.matches([0.5, 0.0, 0.0, 0.0])); } @@ -37,7 +37,7 @@ fn color_matcher_bvh_and_transparency() { fn color_matcher_preserves_distinct_faint_32f_colors_at_zero_tolerance() { let zero = [0.0, 0.0, 0.0, 0.0]; let faint = [0.0, 0.0, 0.0, 8e-6]; - let matcher = ColorMatcher::new([zero, faint], 0.0); + let matcher = ColorMatcher::new([zero, faint], 0.0).unwrap(); assert!(matcher.matches(zero)); assert!(matcher.matches(faint)); assert!(!matcher.matches([0.0, 0.0, 0.0, 2e-5])); @@ -48,7 +48,7 @@ fn color_matcher_coalesces_exact_repeated_colors() { let red = [1.0, 0.0, 0.0, 1.0]; let transparent = [0.0, 0.0, 0.0, 0.0]; let colors = std::iter::repeat_n(red, 10_000).chain(std::iter::repeat_n(transparent, 10_000)); - let matcher = ColorMatcher::new(colors, 0.0); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); assert!(matcher.matches(red)); assert!(matcher.matches(transparent)); assert!(!matcher.matches([0.0, 1.0, 0.0, 1.0])); @@ -64,8 +64,8 @@ fn color_matcher_splits_colors_that_are_all_nearly_equal() { [c, 0.25, 0.75, 1.0] }) .collect(); - let matcher = ColorMatcher::new(selected.iter().copied(), 0.0); - assert!(matches!(matcher.tree, ColorBvh::Node { .. }), "more than 16 colours must not share one leaf"); + let matcher = ColorMatcher::new(selected.iter().copied(), 0.0).unwrap(); + assert!(matcher.tree.nodes.iter().any(|node| matches!(node, ColorNode::Branch { .. })), "more than 16 colours must not share one leaf"); for p in &selected { assert!(matcher.matches(*p)); } @@ -82,7 +82,7 @@ fn color_matcher_tree_matches_brute_force() { }) .collect(); let tol = 0.05; - let matcher = ColorMatcher::new(selected.iter().copied(), tol); + let matcher = ColorMatcher::new(selected.iter().copied(), tol).unwrap(); for i in 0..2000 { let t = i as f32 / 1999.0; let q = [(t * 3.0).fract(), (t * 5.0).fract(), (t * 11.0).fract(), if i % 7 == 0 { 0.0 } else { 1.0 }]; @@ -747,6 +747,45 @@ fn combine_region_matches_combine() { assert!(combine_region(None, None, SelectionMode::Subtract).is_none()); } +#[test] +fn color_matcher_bounds_tightly_clustered_leaves() { + let colors: Vec<_> = (0..1000).map(|i| [i as f32 * 1e-10, 0.0, 0.0, 1.0]).collect(); + let matcher = ColorMatcher::new(colors.iter().copied(), 0.0).unwrap(); + for node in &matcher.tree.nodes { + if let ColorNode::Leaf { start, end } = node { + assert!(end - start <= 16); + } + } + assert!(matcher.matches(colors[999])); + assert!(!matcher.matches([0.1, 0.0, 0.0, 1.0])); +} + +#[test] +fn color_matcher_budget_fires_at_the_distinct_color_it_is_sized_for() { + let colors: Vec<_> = (0..5).map(|i| [i as f32 / 8.0, 0.0, 0.0, 1.0]).collect(); + // Room for exactly `n` distinct colours: the n-th still fits, the (n + 1)-th does not. + for n in 1..=5 { + let budget = n * COLOR_INDEX_BYTES; + assert!(ColorMatcher::with_budget(colors[..n].iter().copied(), 0.0, budget).is_ok(), "{n} colours in {budget} bytes"); + if n < 5 { + assert!(ColorMatcher::with_budget(colors[..=n].iter().copied(), 0.0, budget).is_err(), "{} colours in {budget} bytes", n + 1); + } + } + // A budget short of one colour's charge admits none, and duplicates are never charged. + assert!(ColorMatcher::with_budget([colors[0]], 0.0, COLOR_INDEX_BYTES - 1).is_err()); + let repeated = std::iter::repeat_n(colors[0], 1000); + assert!(ColorMatcher::with_budget(repeated, 0.0, COLOR_INDEX_BYTES).is_ok()); +} + +#[test] +fn color_matcher_default_budget_admits_a_noisy_float_canvas() { + // Every pixel of a 2000x1000 float canvas is its own colour: past the old 1M-colour cap. + let colors = (0..2_000_000u32).map(|i| [i as f32 * 1e-7, (i % 1013) as f32 * 1e-3, 0.5, 1.0]); + let matcher = ColorMatcher::new(colors, 0.0).unwrap(); + assert!(matcher.matches([1_999_999.0 * 1e-7, (1_999_999 % 1013) as f32 * 1e-3, 0.5, 1.0])); + assert!(!matcher.matches([0.9, 0.9, 0.9, 1.0])); +} + #[test] fn polygon_window_keeps_full_canvas_fractional_coverage() { for canvas in [Rect::new(0, 0, 110_000, 12), Rect::new(-123, -7, 109_877, 5)] { @@ -779,3 +818,12 @@ fn feather_leaves_everything_beyond_its_reach_at_zero() { assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}"); } } + +#[test] +fn color_matcher_tree_reservation_failure_is_reported() { + assert!(ColorBvh::reserve_nodes(usize::MAX).is_err()); + for n in 0..1024 { + let tree = ColorBvh::build(vec![[0.0; 4]; n]).unwrap(); + assert_eq!(tree.nodes.len(), ColorBvh::node_count(n)); + } +} diff --git a/crates/algo/src/square_dilate.rs b/crates/algo/src/square_dilate.rs new file mode 100644 index 0000000000..3781d71d99 --- /dev/null +++ b/crates/algo/src/square_dilate.rs @@ -0,0 +1,337 @@ +//! Grow a boolean mask by a clipped square: shared by the Remove Tool and the non-local completion. + +/// `mask` grown by `r` cells with a square structuring element, clipped to the `w × h` grid: a +/// cell is set when any mask cell lies within chessboard distance `r`. A mask of the wrong size +/// comes back unchanged. +/// +/// Work stays proportional to the mask's extent, not the grid's: a mask cell costs `(2r+1)²` +/// stores directly, which is the cheapest for a small or sparse mask; a bulky one is grown by two +/// running-count passes over its own expanded bounding box instead. +pub(crate) fn dilate_square(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + if r == 0 || mask.len() != w.saturating_mul(h) { + return mask.to_vec(); + } + let Some(b) = bounds(w, mask) else { + return vec![false; mask.len()]; + }; + let mut out = vec![false; mask.len()]; + match choose(w, h, &b, r) { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + out +} + +/// The tight box around the set cells (inclusive corners) and how many there are. A mask of at +/// most `KEEP` set cells also keeps their indices (`cells.len() == count`), so the direct path +/// needn't scan the grid a second time. +struct Bounds { + x0: usize, + y0: usize, + x1: usize, + y1: usize, + count: usize, + cells: Vec, +} + +/// Most set cells whose indices `bounds` keeps. +const KEEP: usize = 1 << 12; + +#[derive(Debug, PartialEq, Eq)] +enum Path { + Direct, + Separable, +} + +/// `None` when no cell is set. `mask` has `w` columns. +fn bounds(w: usize, mask: &[bool]) -> Option { + let mut b = Bounds { x0: usize::MAX, y0: usize::MAX, x1: 0, y1: 0, count: 0, cells: Vec::new() }; + // Most cells are unset: skip 64 at a time with a vectorised test before looking at any. + for (c, chunk) in mask.chunks(64).enumerate().filter(|(_, chunk)| chunk.iter().any(|m| *m)) { + for (j, _) in chunk.iter().enumerate().filter(|(_, m)| **m) { + let i = c * 64 + j; + let (x, y) = (i % w, i / w); + // Row-major order: `y` never decreases. + (b.x0, b.y0, b.x1, b.y1, b.count) = (b.x0.min(x), b.y0.min(y), b.x1.max(x), y, b.count + 1); + if b.count <= KEEP { + b.cells.push(i); + } else if !b.cells.is_empty() { + b.cells = Vec::new(); + } + } + } + (b.count > 0).then_some(b) +} + +/// The box around the set cells grown by `r` and clipped to the grid: `(x0, x1, y0, y1)`, inclusive. +fn expanded(w: usize, h: usize, b: &Bounds, r: usize) -> (usize, usize, usize, usize) { + (b.x0.saturating_sub(r), b.x1.saturating_add(r).min(w - 1), b.y0.saturating_sub(r), b.y1.saturating_add(r).min(h - 1)) +} + +/// Cells of scratch the separable path allocates: the rows that can hold set cells, across the +/// expanded box. Never more than the expanded box's area. +#[cfg(test)] +fn scratch_cells(w: usize, h: usize, b: &Bounds, r: usize) -> usize { + let (ex0, ex1, _, _) = expanded(w, h, b, r); + (b.y1 - b.y0 + 1).saturating_mul(ex1 - ex0 + 1) +} + +/// The cheaper way to grow the mask: the direct squares when they cost no more than two passes +/// over the expanded box. +fn choose(w: usize, h: usize, b: &Bounds, r: usize) -> Path { + let side = r.saturating_mul(2).saturating_add(1); + let direct = b.count.saturating_mul(side.saturating_mul(side)); + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let separable = (ex1 - ex0 + 1).saturating_mul(ey1 - ey0 + 1).saturating_mul(2); + if direct <= separable { Path::Direct } else { Path::Separable } +} + +/// Set the clipped `(2r+1)²` square around every set cell. +fn direct(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) { + let mut stamp = |x: usize, y: usize| { + let (sx, ex) = (x.saturating_sub(r), x.saturating_add(r).min(w - 1)); + for ty in y.saturating_sub(r)..=y.saturating_add(r).min(h - 1) { + out[ty * w + sx..=ty * w + ex].fill(true); + } + }; + if b.cells.len() == b.count { + b.cells.iter().for_each(|i| stamp(i % w, i / w)); + return; + } + for y in b.y0..=b.y1 { + for (dx, _) in mask[y * w + b.x0..=y * w + b.x1].iter().enumerate().filter(|(_, m)| **m) { + stamp(b.x0 + dx, y); + } + } +} + +/// Two running-count passes (a cell is set when any cell within `r` along one axis is set), +/// confined to the expanded box. Returns the scratch cells it allocated. +fn separable(w: usize, h: usize, mask: &[bool], r: usize, b: &Bounds, out: &mut [bool]) -> usize { + let (ex0, ex1, ey0, ey1) = expanded(w, h, b, r); + let (bw, eh, rows) = (ex1 - ex0 + 1, ey1 - ey0 + 1, b.y1 - b.y0 + 1); + // Horizontal: every set cell lies in `x0..=x1`, so clipping a row's window at the box instead + // of the grid only drops cells that are unset anyway. Only the rows that hold set cells. + let mut horiz = vec![false; rows * bw]; + for ry in 0..rows { + let src = &mask[(b.y0 + ry) * w + ex0..=(b.y0 + ry) * w + ex1]; + let dst = &mut horiz[ry * bw..(ry + 1) * bw]; + let mut count = 0usize; + for &s in &src[..bw.min(r)] { + count += usize::from(s); + } + for (k, d) in dst.iter_mut().enumerate() { + count += src.get(k.saturating_add(r)).map_or(0, |s| usize::from(*s)); + count -= k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(|j| src.get(j)).map_or(0, |s| usize::from(*s)); + *d = count > 0; + } + } + // Vertical, row-major: the expanded box's rows form a sequence of length `eh` whose element + // `k` is the scratch row of output row `ey0 + k`, or all unset above and below the set rows. + let row = |k: usize| if k < eh { (ey0 + k).checked_sub(b.y0).filter(|j| *j < rows).map(|j| &horiz[j * bw..(j + 1) * bw]) } else { None }; + let mut counts = vec![0usize; bw]; + for k in 0..eh.min(r) { + if let Some(src) = row(k) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + } + for k in 0..eh { + if let Some(src) = row(k.saturating_add(r)) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c += usize::from(*s)); + } + if let Some(src) = k.checked_sub(r).and_then(|j| j.checked_sub(1)).and_then(row) { + counts.iter_mut().zip(src).for_each(|(c, s)| *c -= usize::from(*s)); + } + let dst = &mut out[(ey0 + k) * w + ex0..(ey0 + k) * w + ex1 + 1]; + dst.iter_mut().zip(&counts).for_each(|(d, c)| *d = *c > 0); + } + horiz.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The loop the non-local completion used before this module: the oracle. + fn reference(w: usize, h: usize, mask: &[bool], r: usize) -> Vec { + let ri = r as i32; + let mut targets = vec![false; w * h]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % w) as i32, (i / w) as i32); + for ty in (y - ri).max(0)..=(y + ri).min(h as i32 - 1) { + for tx in (x - ri).max(0)..=(x + ri).min(w as i32 - 1) { + targets[ty as usize * w + tx as usize] = true; + } + } + } + targets + } + + fn forced(w: usize, h: usize, mask: &[bool], r: usize, path: &Path) -> Vec { + let mut out = vec![false; w * h]; + if let Some(b) = bounds(w, mask) { + match path { + Path::Direct => direct(w, h, mask, r, &b, &mut out), + Path::Separable => { + separable(w, h, mask, r, &b, &mut out); + } + } + } + out + } + + /// `dilate_square` and both paths, forced, against the oracle. + fn check(w: usize, h: usize, mask: &[bool], r: usize, what: &str) { + let want = reference(w, h, mask, r); + assert!(dilate_square(w, h, mask, r) == want, "{what}: dilate_square {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Direct) == want, "{what}: direct {w}x{h} r={r}"); + assert!(forced(w, h, mask, r, &Path::Separable) == want, "{what}: separable {w}x{h} r={r}"); + } + + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn mask(&mut self, n: usize, per_mille: u64) -> Vec { + (0..n).map(|_| self.next() % 1000 < per_mille).collect() + } + } + + #[test] + fn every_mask_on_small_grids_matches_the_oracle() { + for w in 1..=3usize { + for h in 1..=3usize { + for bits in 0..1u32 << (w * h) { + let mask: Vec = (0..w * h).map(|i| bits >> i & 1 == 1).collect(); + for r in [1, 2, 3, 5] { + check(w, h, &mask, r, "exhaustive"); + } + } + } + } + } + + fn cases(w: usize, h: usize) -> Vec<(&'static str, Vec)> { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ (w * 31 + h) as u64); + let at = |x: usize, y: usize| y * w + x; + let single = |x: usize, y: usize| { + let mut m = vec![false; w * h]; + m[at(x, y)] = true; + m + }; + let (cx, cy, mx, my) = (w / 2, h / 2, w - 1, h - 1); + let mut two = single(0, 0); + two[at(mx, my)] = true; + let blob = |x0: usize, y0: usize| -> Vec { + (0..w * h).map(|i| (x0..x0 + 3.min(w - x0)).contains(&(i % w)) && (y0..y0 + 3.min(h - y0)).contains(&(i / w))).collect() + }; + let mut corners = blob(0, 0); + corners.iter_mut().zip(blob(w.saturating_sub(3), h.saturating_sub(3))).for_each(|(a, b)| *a |= b); + vec![ + ("empty", vec![false; w * h]), + ("centre", single(cx, cy)), + ("top-left", single(0, 0)), + ("top-right", single(mx, 0)), + ("bottom-left", single(0, my)), + ("bottom-right", single(mx, my)), + ("top edge", single(cx, 0)), + ("bottom edge", single(cx, my)), + ("left edge", single(0, cy)), + ("right edge", single(mx, cy)), + ("sparse", rng.mask(w * h, 5)), + ("dense", rng.mask(w * h, 700)), + ("full", vec![true; w * h]), + ("two corners", two), + ("two blobs", corners), + ("edge blob", blob(0, cy.saturating_sub(1))), + ("horizontal line", (0..w * h).map(|i| i / w == cy).collect()), + ("vertical line", (0..w * h).map(|i| i % w == cx).collect()), + ] + } + + #[test] + fn named_cases_match_the_oracle() { + for (w, h) in [(64, 48), (1, 97), (97, 1), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 2, 3, 5, 16, 200] { + check(w, h, &mask, r, what); + } + } + } + } + + /// More set cells than `bounds` keeps (`KEEP`): it drops its cell list, and the direct path + /// rescans the box instead. + #[test] + fn a_mask_with_more_cells_than_are_kept_matches_the_oracle() { + let (w, h) = (200, 200); + let mask = Rng(0x2545_f491_4f6c_dd1d).mask(w * h, 130); + let Some(b) = bounds(w, &mask) else { panic!("cells are set") }; + assert!(b.count > KEEP && b.cells.is_empty(), "{} cells, {} kept", b.count, b.cells.len()); + check(w, h, &mask, 1, "more than KEEP"); + } + + #[test] + fn the_remove_tool_dilate_is_unchanged() { + for (w, h) in [(64, 48), (1, 97), (7, 3)] { + for (what, mask) in cases(w, h) { + for r in [1, 3, 16] { + assert!(crate::remove::dilate(w, h, &mask, r) == reference(w, h, &mask, r), "{what} {w}x{h} r={r}"); + } + assert!(crate::remove::dilate(w, h, &mask, 0) == mask, "{what}: r = 0"); + } + } + assert_eq!(dilate_square(4, 4, &[true; 5], 2), vec![true; 5], "a mask of the wrong size"); + assert_eq!(dilate_square(0, 0, &[], 2), Vec::::new()); + } + + #[test] + fn a_small_or_sparse_mask_takes_the_direct_path_however_big_the_grid() { + let (w, h) = (600, 400); + let mut one = vec![false; w * h]; + one[200 * w + 300] = true; + let Some(b) = bounds(w, &one) else { panic!("one cell is set") }; + assert_eq!(choose(w, h, &b, 3), Path::Direct); + let mut two = vec![false; w * h]; + two[0] = true; + two[w * h - 1] = true; + let Some(b) = bounds(w, &two) else { panic!("two cells are set") }; + assert_eq!((b.x0, b.y0, b.x1, b.y1), (0, 0, w - 1, h - 1), "the box is the whole grid"); + assert_eq!(choose(w, h, &b, 3), Path::Direct); + } + + #[test] + fn a_bulky_mask_takes_the_separable_path_with_bounded_scratch() { + let (w, h) = (240, 240); + let mut rng = Rng(7); + let mut mask = vec![false; w * h]; + for y in 20..220 { + for x in 20..220 { + mask[y * w + x] = rng.next().is_multiple_of(2); + } + } + let Some(b) = bounds(w, &mask) else { panic!("the patch has set cells") }; + assert_eq!(choose(w, h, &b, 3), Path::Separable); + let (ex0, ex1, ey0, ey1) = expanded(w, h, &b, 3); + let scratch = scratch_cells(w, h, &b, 3); + assert!(scratch <= (ex1 - ex0 + 1) * (ey1 - ey0 + 1), "scratch stays inside the expanded box"); + assert!(scratch < w * h, "and is smaller than the grid when the mask is inside it"); + let mut out = vec![false; w * h]; + assert_eq!(separable(w, h, &mask, 3, &b, &mut out), scratch); + } + + #[test] + fn huge_radii_do_not_overflow() { + let mask = [false, true, false, false]; + assert_eq!(dilate_square(2, 2, &mask, usize::MAX), vec![true; 4]); + assert_eq!(forced(2, 2, &mask, usize::MAX, &Path::Separable), vec![true; 4]); + } +} diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 50e8e220ac..17d8f9faf7 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -13,6 +13,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-vector = { workspace = true } +photocraft-cms = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } @@ -21,7 +22,6 @@ rayon = { workspace = true } proptest = { workspace = true } criterion = { workspace = true } photocraft-paint = { workspace = true } -photocraft-cms = { workspace = true } [[bench]] name = "compose" diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2af1c2e8fb..e93e0818f3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod effects; pub mod fill_layout; pub mod filter_mask; pub mod gradient_fill; +pub mod lut_rows; pub mod masks; pub mod multichannel; pub mod native; @@ -82,8 +83,8 @@ use render::in_blend_space; use render::render_tiled_with; pub(crate) use render::visible_layers; pub use render::{ - BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, flatten, flatten_to_surface, render, render_bands, render_layer, - render_layer_in, render_tiled, + BAND_PIXELS, MAX_RENDER_PIXELS, band_rows_for, cmyk_space, composite_to_surface, composite_to_surface_with_interrupt, flatten, flatten_to_surface, render, + render_bands, render_layer, render_layer_in, render_tiled, }; /// Straight-alpha RGBA float buffer covering a rectangle. diff --git a/crates/compose/src/lut_rows.rs b/crates/compose/src/lut_rows.rs new file mode 100644 index 0000000000..b0e525e786 --- /dev/null +++ b/crates/compose/src/lut_rows.rs @@ -0,0 +1,197 @@ +//! Adjustment LUT rows (4096 entries each) kept between renders, keyed by a hash of what they're +//! built from. The GPU planner rebuilt the Levels tables on every plan, 1–2 ms per layer (#11498); +//! callers hash exactly the builder's inputs, so settings that don't change the rows share an +//! entry. Most recently used first, at most [`CAPACITY`] entries +//! (32 × 3 rows × 16 KiB = 1.5 MiB); [`purge_cache`] empties it (Edit › Purge › All). + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Entries in one LUT row. +pub const ROW_LEN: usize = 4096; + +/// The LUT rows of one adjustment state. +pub type Rows = Vec<[f32; ROW_LEN]>; + +/// Adjustment states the shared cache remembers. +pub const CAPACITY: usize = 32; + +/// A most-recently-used list of row sets by key. +#[derive(Debug)] +pub struct RowCache { + entries: VecDeque<(u64, Arc)>, + capacity: usize, + generation: u64, +} + +impl RowCache { + /// An empty cache holding at most `capacity` row sets. + pub fn new(capacity: usize) -> Self { + Self { entries: VecDeque::new(), capacity, generation: 0 } + } + + /// The rows stored for `key`, now the most recently used. + fn get(&mut self, key: u64) -> Option> { + let entry = self.entries.iter().position(|(k, _)| *k == key).and_then(|i| self.entries.remove(i))?; + let rows = Arc::clone(&entry.1); + self.entries.push_front(entry); + Some(rows) + } + + /// Store `rows` for `key` (unless another builder got there first), dropping the oldest entry + /// when full. + fn insert(&mut self, key: u64, rows: Arc) { + if !self.entries.iter().any(|(k, _)| *k == key) { + self.entries.push_front((key, rows)); + self.entries.truncate(self.capacity); + } + } + + /// Row sets held. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether nothing is held. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Bytes of rows held. + pub fn bytes(&self) -> usize { + self.entries.iter().map(|(_, r)| r.len().saturating_mul(std::mem::size_of::<[f32; ROW_LEN]>())).sum() + } + + /// Drop every entry, returning the bytes they held. + pub fn clear(&mut self) -> usize { + let freed = self.bytes(); + self.entries.clear(); + self.generation = self.generation.wrapping_add(1); + freed + } +} + +/// The rows for `key` in `cache`, built by `build` only when it doesn't hold them. `build` runs +/// outside the lock, so other renders never wait on it. +pub fn cached_in(cache: &Mutex, key: u64, build: impl FnOnce() -> Rows) -> Rows { + let (hit, generation) = { + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + (cache.get(key), cache.generation) + }; + if let Some(rows) = hit { + return rows.to_vec(); + } + let rows = build(); + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + // A purge must not be undone by a build that was already in flight. + if cache.generation == generation { + cache.insert(key, Arc::new(rows.clone())); + } + rows +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(RowCache::new(CAPACITY))) +} + +/// [`cached_in`] the shared cache. +pub fn cached(key: u64, build: impl FnOnce() -> Rows) -> Rows { + cached_in(cache(), key, build) +} + +/// Bytes of rows the shared cache holds. +pub fn cache_bytes() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).bytes() +} + +/// Drop every cached row set (Edit › Purge › All), returning the bytes they held. +pub fn purge_cache() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).clear() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + fn rows(v: f32) -> Rows { + vec![[v; ROW_LEN]; 3] + } + + #[test] + fn a_known_key_is_served_without_building() { + let cache = Mutex::new(RowCache::new(4)); + let builds = Cell::new(0); + let get = |key: u64, v: f32| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(v) + }) + }; + assert_eq!(get(1, 0.25)[2][7], 0.25); + assert_eq!(get(1, 0.75)[2][7], 0.25, "the stored rows, not a rebuild"); + assert_eq!(builds.get(), 1); + get(2, 0.5); + assert_eq!(builds.get(), 2); + } + + #[test] + fn the_least_recently_used_entry_is_dropped_first() { + let cache = Mutex::new(RowCache::new(CAPACITY)); + let builds = Cell::new(0); + let get = |key: u64| { + cached_in(&cache, key, || { + builds.set(builds.get() + 1); + rows(0.0) + }) + }; + // A slider drag through many states keeps only the most recent ones. + for key in 0..200 { + get(key); + } + assert_eq!(cache.lock().map(|c| c.len()).unwrap_or(0), CAPACITY); + assert_eq!(builds.get(), 200); + // Key 168 is the oldest kept: reading it makes 169 the oldest, which the next new key drops. + get(168); + get(1000); + assert_eq!(builds.get(), 201); + get(168); + assert_eq!(builds.get(), 201, "kept: it was used recently"); + get(169); + assert_eq!(builds.get(), 202, "dropped: the oldest when the cache was full"); + } + + #[test] + fn purge_discards_in_flight_builds() { + use std::sync::mpsc; + let cache = Mutex::new(RowCache::new(CAPACITY)); + let (started_tx, started_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let cache_ref = &cache; + scope.spawn(move || { + cached_in(cache_ref, 123, || { + started_tx.send(()).unwrap(); + resume_rx.recv().unwrap(); + rows(0.5) + }) + }); + started_rx.recv().unwrap(); + cache.lock().unwrap().clear(); + resume_tx.send(()).unwrap(); + }); + assert!(cache.lock().unwrap().is_empty(), "a build started before purge must not refill the cache"); + } + + #[test] + fn clearing_reports_the_bytes_held() { + let mut cache = RowCache::new(CAPACITY); + cache.insert(1, Arc::new(rows(0.0))); + cache.insert(2, Arc::new(vec![[0.0; ROW_LEN]])); + assert_eq!(cache.bytes(), 4 * ROW_LEN * 4); + assert_eq!(cache.clear(), 4 * ROW_LEN * 4); + assert!(cache.is_empty()); + assert_eq!(cache.bytes(), 0); + } +} diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 4eedc88619..ea6c11b5b0 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -1,49 +1,215 @@ -//! Display of Multichannel documents: every spot (ink) channel printed over white paper. +//! Spot inks printed over a picture: every spot (ink) channel of a Multichannel document over +//! white paper, and the visible spot channels of an RGB or CMYK document over its composite +//! (the canvas preview and the PSD thumbnail; never `flatten`, export or the merged image). //! //! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity //! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is -//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document -//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. -//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. +//! how the reference editor previews spot channels. Inks print in Channels panel order. An +//! alpha-type channel of a Multichannel document (selected / masked areas) still prints, as a +//! transparent black ink, like the reference editor shows it. Its value is stored as-is +//! (1 = selected, white in the Channels panel), so its ink is `1 − value`. -use photocraft_doc::Document; +//! This is a screen approximation, not a press proof: stored RGB alternate colours are taken as +//! sRGB and, like Lab/gray alternates, converted into an RGB document's own profile; CMYK +//! alternates use the document CMYK profile. No spectral ink or paper interaction is modelled. +//! At full coverage, solidity 0 multiplies the underlying colour and solidity 1 replaces it. +//! Previewing borrows the document and only changes the supplied display buffer. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, ColorSpace, Profile, Transform, TransformOptions}; +use photocraft_color::convert::CmykSpace; +use photocraft_color::{Color, ColorMode}; +use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; use crate::Buffer; -/// The printed inks of `doc` over `rect` (opaque). -pub fn inks(doc: &Document, rect: Rect) -> Buffer { - let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); - let mut vals = Vec::new(); - for ch in &doc.channels { - let alpha = ch.spot.is_none(); - let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - // An empty alpha channel (all black) is solid black ink, so it can't be skipped. - if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { - continue; - } - let c = ink.to_rgb(); - let s = solidity.clamp(0.0, 1.0); - let n = ch.surface.channels(); - ch.surface.read_region_into(rect, &mut vals); - for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); - if v <= 0.0 { +/// One channel printed as an ink: its display colour in the composite's colour space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ink { + pub rgb: [f32; 3], + pub solidity: f32, + /// An alpha-type channel: black ink where the channel is unselected (`1 − value`). + pub alpha: bool, +} + +impl Ink { + /// The ink of channel `ch` of `doc`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. Printing several inks, build one [`InkSpace`] and use [`InkSpace::ink`]. + pub fn of(doc: &Document, ch: &AlphaChannel) -> Ink { + InkSpace::of(doc).ink(ch) + } + + /// Print this ink with channel values `values` (one per pixel) over the opaque `px`. + pub fn apply(&self, px: &mut [[f32; 4]], values: impl IntoIterator) { + let (c, s) = (self.rgb, self.solidity); + for (p, v) in px.iter_mut().zip(values) { + let v = if self.alpha { 1.0 - v } else { v }; + if v.is_nan() || v <= 0.0 { continue; } + let v = v.min(1.0); let keep = 1.0 - s * v; for j in 0..3 { p[j] = keep * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]; } } } +} + +/// How spot colours of one document map into its composite space. Build it once per render: +/// the document's profiles are parsed here, not per ink or per damage rect. +pub struct InkSpace { + cmyk: Option>, + /// sRGB → an RGB document's own profile, when that differs from sRGB. + to_own: Option>, +} + +impl InkSpace { + pub fn of(doc: &Document) -> InkSpace { + let to_own = if doc.mode == ColorMode::Rgb { + doc.icc_profile + .as_ref() + .and_then(|b| Profile::parse(b).ok()) + .filter(|p| p.color_space == ColorSpace::Rgb && !p.same_colors(Builtin::Srgb.profile())) + .and_then(|own| photocraft_cms::transform::cached(Builtin::Srgb.profile(), &own, TransformOptions::default()).ok()) + } else { + None + }; + InkSpace { cmyk: crate::cmyk_space(doc), to_own } + } + + /// The ink of channel `ch`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. + pub fn ink(&self, ch: &AlphaChannel) -> Ink { + match ch.spot { + Some((color, solidity)) => Ink { rgb: self.rgb(color), solidity: solidity.clamp(0.0, 1.0), alpha: false }, + None => Ink { rgb: [0.0; 3], solidity: 0.0, alpha: true }, + } + } + + /// The display colour of spot colour `color` in the composite space: CMYK colours through the + /// document's CMYK profile (the built-in coated CMYK otherwise), and RGB (taken as sRGB), CMYK, + /// Lab and gray colours of an RGB document converted from sRGB into its own profile, so an ink + /// keeps its colour in a linear (32-bit), Adobe RGB or ProPhoto document. + pub fn rgb(&self, color: Color) -> [f32; 3] { + if matches!(color.mode, ColorMode::Indexed | ColorMode::Multichannel) { + return color.to_rgb(); + } + let mut rgb = photocraft_color::convert::with_cmyk_space(self.cmyk.as_ref(), || color.to_rgb()); + if let Some(t) = &self.to_own { + t.apply(&mut rgb, 3); + } + rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }) + } +} + +/// Print the channels `visible(i)` picks from `doc.channels` (in panel order) as inks over the +/// opaque `buf`, reading them over `buf.rect`. +pub fn overprint(buf: &mut Buffer, doc: &Document, visible: impl Fn(usize) -> bool) { + let rect = buf.rect; + let space = InkSpace::of(doc); + let mut vals = Vec::new(); + for (i, ch) in doc.channels.iter().enumerate() { + if !visible(i) { + continue; + } + let ink = space.ink(ch); + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !ink.alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { + continue; + } + let n = ch.surface.channels().max(1); + ch.surface.read_region_into(rect, &mut vals); + ink.apply(&mut buf.px, vals.chunks_exact(n).map(|v| v[0])); + } +} + +/// Whether channel `i` of `doc` is a spot ink that prints over an RGB or CMYK composite. +pub fn is_overprint_spot(doc: &Document, i: usize) -> bool { + matches!(doc.mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Grayscale | ColorMode::Lab) && doc.channels.get(i).is_some_and(|c| c.spot.is_some()) +} + +/// The printed inks of `doc` over `rect` (opaque). +pub fn inks(doc: &Document, rect: Rect) -> Buffer { + let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); + overprint(&mut buf, doc, |_| true); buf } /// The starting backdrop of a document render: the inks for Multichannel, else transparent. pub(crate) fn backdrop(doc: &Document, rect: Rect) -> Buffer { - if doc.mode == photocraft_color::ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } + if doc.mode == ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } +} + +/// [`crate::thumbnail_buffer`] of an RGB or CMYK document with its spot channels printed over it +/// (matted over white, so opaque, where there is ink): the document's preview, as the reference +/// editor saves it. +/// Unchanged when no spot channel has ink. +pub fn thumbnail_with_inks(doc: &Document, max_side: u32) -> Buffer { + let mut buf = crate::thumbnail_buffer(doc, max_side); + let spots: Vec = (0..doc.channels.len()) + .filter(|&i| is_overprint_spot(doc, i) && doc.channels.get(i).is_some_and(|c| !c.surface.content_bounds().intersect(&doc.bounds()).is_empty())) + .collect(); + let (w, h) = (buf.rect.width(), buf.rect.height()); + if spots.is_empty() || w == 0 || h == 0 || buf.px.len() != w as usize * h as usize { + return buf; + } + let space = InkSpace::of(doc); + let inks: Vec<(Ink, Vec)> = + spots.into_iter().filter_map(|i| doc.channels.get(i)).map(|ch| (space.ink(ch), reduce(&ch.surface, doc.bounds(), w, h))).collect(); + // Only pixels with ink are matted over white (the inks print on paper); the rest keep their + // transparency. + for (k, p) in buf.px.iter_mut().enumerate() { + if inks.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0)) { + let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) }; + *p = [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]; + } + } + for (ink, vals) in inks { + ink.apply(&mut buf.px, vals); + } + buf +} + +/// Samples per side of a thumbnail pixel that [`reduce`] averages at most. +const REDUCE_SAMPLES: i32 = 4; + +/// The first channel of `s` over `area`, averaged down to `w`×`h`, one output row at a time. +/// A box up to [`REDUCE_SAMPLES`] pixels a side is averaged whole; a bigger one at that many +/// evenly spaced rows and columns, so a huge document costs about `h × 4` row reads, not a scan. +fn reduce(s: &photocraft_raster::Surface, area: Rect, w: u32, h: u32) -> Vec { + let (sw, sh) = (u64::from(area.width()), u64::from(area.height())); + let n = s.channels().max(1); + let edge = |i: u32, out: u32, len: u64| (u64::from(i) * len / u64::from(out.max(1))) as i32; + // The offsets from a box's start that are sampled. + let picks = |len: i32| -> Vec { + if len <= REDUCE_SAMPLES { (0..len).collect() } else { (0..REDUCE_SAMPLES).map(|j| ((2 * j + 1) * len) / (2 * REDUCE_SAMPLES)).collect() } + }; + let mut out = Vec::with_capacity(w as usize * h as usize); + let mut row = Vec::new(); + let mut sums = vec![(0.0f32, 0u32); w as usize]; + for y in 0..h { + let (y0, y1) = (edge(y, h, sh), edge(y + 1, h, sh)); + sums.iter_mut().for_each(|t| *t = (0.0, 0)); + for dy in picks((y1 - y0).max(1)) { + let sy = area.y0 + y0 + dy; + s.read_region_into(Rect::new(area.x0, sy, area.x1, sy + 1), &mut row); + for (x, t) in sums.iter_mut().enumerate() { + let (x0, x1) = (edge(x as u32, w, sw), edge(x as u32 + 1, w, sw)); + for dx in picks((x1 - x0).max(1)) { + if let Some(v) = row.get((x0 + dx) as usize * n) { + t.0 += v; + t.1 += 1; + } + } + } + } + out.extend(sums.iter().map(|&(sum, count)| if count > 0 { sum / count as f32 } else { 0.0 })); + } + out } #[cfg(test)] @@ -70,7 +236,7 @@ mod tests { assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is - // unselected, as Photoshop shows it. + // unselected, as the reference editor shows it. let mut k = Surface::new(fmt); k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); @@ -83,4 +249,169 @@ mod tests { doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); } + + /// One spot channel of `depth` holding `vals` (a row), ink `rgb` at `solidity`. + fn spot(depth: SampleType, name: &str, rgb: [f32; 3], solidity: f32, vals: &[f32]) -> AlphaChannel { + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_region(Rect::new(0, 0, vals.len() as i32, 1), vals); + AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), solidity)), ..AlphaChannel::new(name, s) } + } + + /// The ink formula written out for one ink over `p`. + fn print(p: [f32; 3], c: [f32; 3], s: f32, v: f32) -> [f32; 3] { + std::array::from_fn(|j| (1.0 - s * v) * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]) + } + + fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool { + a[3] == 1.0 && a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + /// Spot channels never reach the composite of an RGB or CMYK document: flatten, the merged + /// image and export are the same with and without them. + #[test] + fn spots_stay_out_of_rgb_and_cmyk_flatten() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("d", Size::new(4, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let plain = crate::flatten(&doc); + doc.channels.push(spot(depth, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 0.5, 0.0, 1.0])); + doc.channels.push(spot(depth, "pink", [1.0, 0.3, 0.6], 1.0, &[1.0, 1.0, 1.0, 1.0])); + assert_eq!(crate::flatten(&doc).px, plain.px, "{mode:?} {depth:?}"); + let mut bare = doc.clone(); + bare.channels.clear(); + assert_eq!(crate::render(&doc, doc.bounds()).px, crate::render(&bare, doc.bounds()).px, "{mode:?} {depth:?}"); + } + } + } + + /// Two overlapping inks over a coloured picture, at every solidity and coverage, in both + /// orders and at 8 and 16 bits: the second ink prints over the first. + #[test] + fn overprint_follows_the_ink_formula_in_panel_order() { + let (a, b) = ([0.0, 0.6, 0.65], [0.9, 0.2, 0.5]); + let under = [0.8, 0.7, 0.3]; + for depth in [SampleType::U8, SampleType::U16] { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for sa in [0.0, 0.5, 1.0] { + for sb in [0.0, 0.5, 1.0] { + let cover = [0.0, 0.5, 1.0]; + let mut doc = Document::new("o", Size::new(3, 1), mode, depth); + doc.channels.push(spot(depth, "a", a, sa, &cover)); + doc.channels.push(spot(depth, "b", b, sb, &[1.0, 0.5, 0.0])); + for swapped in [false, true] { + if swapped { + doc.channels.reverse(); + } + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |_| true); + let vals: Vec<(f32, f32)> = vec![(0.0, 1.0), (0.5, 0.5), (1.0, 0.0)]; + for (x, &(va, vb)) in vals.iter().enumerate() { + let want = if swapped { print(print(under, b, sb, vb), a, sa, va) } else { print(print(under, a, sa, va), b, sb, vb) }; + // 0.5 is stored as 128/255 at 8 bits. + assert!(close(buf.px[x], want, 0.006), "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}: {:?} vs {want:?}", buf.px[x]); + } + if swapped { + doc.channels.reverse(); + } + } + // Only the channels `visible` picks print. + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |i| i == 0); + assert!(close(buf.px[2], print(under, a, sa, 1.0), 1e-5)); + } + } + } + } + } + + /// A solid transparent ink over white shows its own colour; a solid opaque one covers any colour. + #[test] + fn solid_inks_show_their_colour() { + let teal = [0.0, 157.0 / 255.0, 165.0 / 255.0]; + let mut doc = Document::new("s", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + doc.channels.push(spot(SampleType::U8, "teal", teal, 0.0, &[1.0])); + let mut buf = Buffer::filled(doc.bounds(), [1.0; 4]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + doc.channels[0].spot = Some((Color::rgb(teal[0], teal[1], teal[2]), 1.0)); + let mut buf = Buffer::filled(doc.bounds(), [0.2, 0.9, 0.1, 1.0]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + } + + #[test] + fn hidden_spot_leaves_the_process_composite_unchanged() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("hidden", Size::new(1, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + doc.channels.push(spot(depth, "opaque", [0.0, 0.6, 0.65], 1.0, &[1.0])); + let before = crate::flatten(&doc); + let mut preview = before.clone(); + overprint(&mut preview, &doc, |_| false); + assert_eq!(preview.px, before.px); + overprint(&mut preview, &doc, |_| true); + assert!(close(preview.px[0], [0.0, 0.6, 0.65], 1e-6)); + assert_eq!(crate::flatten(&doc).px, before.px); + assert_eq!(doc.channels[0].spot, Some((Color::rgb(0.0, 0.6, 0.65), 1.0))); + } + } + } + + /// A CMYK spot colour displays through the document's CMYK profile, as the CMYK composite does. + #[test] + fn cmyk_ink_colours_go_through_the_document_profile() { + let doc = Document::new("c", Size::new(1, 1), ColorMode::Cmyk, SampleType::U8); + let cyan = Color { mode: ColorMode::Cmyk, c: [1.0, 0.0, 0.0, 0.0], alpha: 1.0 }; + let space = crate::cmyk_space(&doc); + let want = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cyan.to_rgb()); + assert_eq!(InkSpace::of(&doc).rgb(cyan), want.map(|v| v.clamp(0.0, 1.0))); + // RGB inks are taken as stored. + assert_eq!(InkSpace::of(&doc).rgb(Color::rgb(0.1, 0.2, 0.3)), [0.1, 0.2, 0.3]); + } + + /// An RGB spot colour is sRGB, like the other alternates: an sRGB document takes it as stored, + /// a linear (32-bit) one gets its linear values, which the canvas encodes back to the colour. + #[test] + fn rgb_ink_colours_convert_into_the_document_profile() { + let teal = Color::rgb(0.0, 0.6, 0.65); + let mut doc = Document::new("l", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + assert_eq!(InkSpace::of(&doc).rgb(teal), [0.0, 0.6, 0.65]); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let got = InkSpace::of(&doc).rgb(teal); + let want = [0.0, 0.6, 0.65].map(photocraft_color::convert::srgb_to_linear); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() <= 0.005), "{got:?} vs {want:?}"); + } + + /// The thumbnail prints the spot inks over the composite (matted over white). + #[test] + fn thumbnail_prints_spot_inks() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut doc = Document::new("t", Size::new(4, 1), mode, SampleType::U8); + let plain = thumbnail_with_inks(&doc, 160); + assert_eq!(plain.px, crate::thumbnail_buffer(&doc, 160).px, "no ink: unchanged"); + doc.channels.push(spot(SampleType::U8, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 1.0, 0.0, 0.0])); + let t = thumbnail_with_inks(&doc, 160); + assert!(close(t.px[0], [0.0, 0.6, 0.65], 0.003), "{mode:?} {:?}", t.px[0]); + // No ink: the transparent pixel stays transparent (the writer mattes it later). + assert_eq!(t.px[3], plain.px[3], "{mode:?}"); + // Halved: each thumbnail pixel averages two document pixels. + let t = thumbnail_with_inks(&doc, 2); + assert!(close(t.px[0], print([1.0; 3], [0.0, 0.6, 0.65], 0.0, 1.0), 0.003), "{:?}", t.px); + assert_eq!(t.px[1], crate::thumbnail_buffer(&doc, 2).px[1], "no ink: unchanged"); + } + } + + /// A box bigger than the sample grid averages evenly spaced samples: ink on the left half of + /// a 64-pixel box reads half covered. + #[test] + fn reduce_samples_big_boxes() { + let mut sf = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let vals: Vec = (0..64).map(|x| if x < 32 { 1.0 } else { 0.0 }).collect(); + sf.write_region(Rect::new(0, 0, 64, 1), &vals); + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 1, 1); + assert_eq!(r, [0.5]); + // Small boxes stay exact. + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 16, 1); + assert_eq!(r, (0..16).map(|x| if x < 8 { 1.0 } else { 0.0 }).collect::>()); + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index e0b68a6b51..aa56917f40 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -21,16 +21,33 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); let w = x1.saturating_sub(x0).max(0) as usize; - let mut row = vec![0u8; w * bpp]; - for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); - for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. - if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { - px.copy_from_slice(from); + // Rows are read in parallel, a chunk at a time, so the scratch stays small. + const CHUNK_ROWS: i32 = 64; + let mut chunk = Vec::new(); + let mut y = y0; + while y < y1 { + let end = y.saturating_add(CHUNK_ROWS).min(y1); + chunk.clear(); + chunk.resize(w * (end - y) as usize * bpp, 0u8); + let fill = |(row, oy): (&mut [u8], i32)| { + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); + for (i, px) in row.chunks_exact_mut(bpp).enumerate() { + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } + }; + let rows = (w * bpp).max(1); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + chunk.par_chunks_mut(rows).zip(y..end).for_each(fill); } - out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + #[cfg(target_arch = "wasm32")] + chunk.chunks_mut(rows).zip(y..end).for_each(fill); + out.write_interleaved(Rect::new(x0, y, x1, end), &chunk); + y = end; } out } diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 250a3ebce1..4f5d629f3a 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -357,3 +357,30 @@ pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer { buf }) } + +/// Composite in bounded bands, retaining native colour-model paths, with cancellation. +pub fn composite_to_surface_with_interrupt( + doc: &Document, + rect: Rect, + fmt: photocraft_color::PixelFormat, + background: Option<[f32; 3]>, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check()?; + let mut out = Surface::new(fmt); + let rows = band_rows_for(rect.width(), 64); + let mut y = rect.y0; + while y < rect.y1 { + ctl.check()?; + let end = y.saturating_add(rows).min(rect.y1); + let band = Rect::new(rect.x0, y, rect.x1, end); + let part = composite_to_surface(doc, band, fmt, background); + ctl.check()?; + let mut pixels = Vec::new(); + part.read_region_into(band, &mut pixels); + out.write_region(band, &pixels); + y = end; + } + out.prune(); + Ok(out) +} diff --git a/crates/compose/tests/interrupt_surface.rs b/crates/compose/tests/interrupt_surface.rs new file mode 100644 index 0000000000..2131c52b79 --- /dev/null +++ b/crates/compose/tests/interrupt_surface.rs @@ -0,0 +1,36 @@ +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_compose::{composite_to_surface, composite_to_surface_with_interrupt}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Interrupt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[test] +fn controlled_composite_keeps_native_pixels_and_cancels_between_bands() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("bands", Size::new(16, 600), mode, depth); + let fmt = PixelFormat::new(mode, depth, true); + let mut layer = Layer::raster("pixels", fmt); + let (channels, colors) = (fmt.channels(), mode.color_channels()); + for y in 0..600 { + // Every row and channel differs, and every pixel is opaque: a band at the wrong + // offset or with a swapped channel changes the result. + let pixel: Vec = + (0..channels).map(|c| if c == colors { 1.0 } else { (1 + (y as usize % 29) * channels + c * 7) as f32 / 255.0 }).collect(); + layer.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, y, 16, 1), &pixel); + } + // What the layer holds: an opaque single raster layer composites to its own pixels. + let stored = layer.surface().unwrap().read_region(doc.bounds()); + doc.layers.push(layer); + let expected = composite_to_surface(&doc, doc.bounds(), fmt, None); + let actual = composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::NONE).unwrap(); + assert_eq!(actual.read_region(doc.bounds()), expected.read_region(doc.bounds()), "{mode:?} {depth:?}"); + assert_eq!(actual.read_region(doc.bounds()), stored, "the composite keeps the layer's pixels: {mode:?} {depth:?}"); + let calls = AtomicUsize::new(0); + let cancelled = || calls.fetch_add(1, Ordering::Relaxed) >= 3; + let progress = |_| {}; + assert!(composite_to_surface_with_interrupt(&doc, doc.bounds(), fmt, None, &Interrupt::new(&cancelled, &progress)).is_err()); + } + } +} diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 5d58cfae88..bf5dc177f6 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -27,6 +27,18 @@ pub(crate) fn capacity(len: usize, purpose: &str) -> Result> { Ok(out) } +/// `Vec::push` that reports an allocation failure instead of aborting; each call is one +/// attempt for the test failpoints. +pub(crate) fn try_push(out: &mut Vec, value: T, purpose: &str) -> Result<()> { + #[cfg(test)] + if failpoint_hits() { + return Err(error(purpose, "not enough memory")); + } + out.try_reserve(1).map_err(|_| error(purpose, "the buffer could not grow"))?; + out.push(value); + Ok(()) +} + /// Check an operation boundary where downstream encoders/decoders allocate internally. /// The test-only failpoint lets command tests prove their transaction boundary. pub(crate) fn checkpoint(purpose: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index 4608186f01..0bf2a335ad 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -188,6 +188,8 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess // Color Sampler points are view state a step may move (crop, rotation): roll them back too, so // a failed second step can't leave them pinned to pixels the restored document no longer has. let samplers = st.color_samplers.clone(); + // So is the painting symmetry, which a rotation moves with the pixels. + let symmetry = st.symmetry.clone(); let r = f(s); if r.is_err() { s.journal.truncate(journal); @@ -212,6 +214,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess st.channel_view = channel_view; st.coalesce = coalesce; st.color_samplers = samplers; + st.symmetry = symmetry; st.revision += 1; // A failed compound does not make an otherwise untouched document dirty. if was_clean { diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 093c683fa1..a860b50db5 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -54,9 +54,10 @@ fn can_split(s: &Session) -> std::result::Result<(), String> { Ok(()) } } +/// Merge Channels needs a document per colour channel: three at least (RGB and Lab; CMYK four). fn can_merge(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) } + if mergeable(s).len() >= 3 { Ok(()) } else { Err("Merge Channels needs at least three open grayscale documents of the same size".into()) } } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index a640a7b45b..3a56aefc3b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1186,6 +1186,25 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } +/// An opened RGB or CMYK document shows its spot channels (they print over the composite) and +/// hides its alpha channels; a Multichannel document's inks are its composite, so their eyes stay off. +#[test] +fn opened_documents_show_their_spot_channels() { + use photocraft_color::{Color, ColorMode, PixelFormat}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Multichannel] { + let mut d = Document::new("spots", photocraft_doc::Size::new(4, 4), mode, SampleType::U8); + let fmt = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + d.channels.push(AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(fmt))); + d.channels.push(AlphaChannel { spot: Some((Color::rgb(0.0, 0.6, 0.65), 0.0)), ..AlphaChannel::new("teal", photocraft_raster::Surface::new(fmt)) }); + let mut s = Session::new(); + s.open_document(d, None); + let v = &s.active().unwrap().channel_view; + let spot_on = mode != ColorMode::Multichannel; + assert_eq!((v.alpha_shown(0), v.alpha_shown(1)), (false, spot_on), "{mode:?}"); + assert_eq!(v.visible_colors(color_count(&s.active().unwrap().doc)), color_count(&s.active().unwrap().doc), "{mode:?}: the composite stays on"); + } +} + /// Split Channels closes the original, so as in the reference application it splits only an /// image with a single layer: two layers (even with one hidden) or a lone group are refused and /// the document stays open with its layers; a flattened image or a lone normal layer still @@ -1219,3 +1238,21 @@ fn split_refuses_an_image_with_more_than_one_layer() { assert!(s.execute("channel.split", json!({})).is_err()); assert_eq!(s.documents().len(), 1); } + +/// Merge Channels is offered from three grayscale documents of one size, the fewest a mode +/// (RGB or Lab) merges; two never merge (#11451). +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut s = Session::new(); + for _ in 0..2 { + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + s.execute("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + s.set_active(0); + let why = s.disabled_reason("channel.merge").expect("two of one size are not enough"); + assert!(why.contains("three"), "{why}"); + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + assert!(s.is_enabled("channel.merge")); + s.execute("channel.merge", json!({"mode": "lab"})).unwrap(); + assert_eq!(s.documents().len(), 2, "the three of one size merged; the other is left"); +} diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 5fb2d7d0f2..a93c580829 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -397,16 +397,16 @@ impl ColorState { pub fn display_transform_for(&self, doc: &Document, display: Option) -> Result> { let src = self.canvas_display_for(doc, display)?.source.clone(); let dst = self.monitor_for(display); - let pv = self.proof(doc.id); - let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); + // Borrowed: the canvas asks per frame, and the default proof state allocates a profile. + let pv = self.proof_ref(doc.id).filter(|pv| pv.enabled); + let proof = pv.map(|pv| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); let key = (src.content_hash(), dst.content_hash(), proof); if let Some(t) = self.display_cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return Ok(t.clone()); } - let t = if pv.enabled { - Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper) - } else { - Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC) + let t = match pv { + Some(pv) => Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper), + None => Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC), } .map_err(cms_err)?; let t = Arc::new(t); diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index b4e9c5cd27..5a9ce8c2c5 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -77,6 +77,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(strokes); items.push("shape stroke cache"); } + let luts = photocraft_compose::lut_rows::purge_cache(); + if luts > 0 { + freed = freed.saturating_add(luts); + items.push("adjustment table cache"); + } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) }; @@ -102,6 +107,7 @@ pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { || crate::smart_cmds::stack_cache_bytes() > 0 || crate::smart_cmds::source_cache_bytes() > 0 || photocraft_paint::mip_cache_bytes() > 0 + || photocraft_compose::lut_rows::cache_bytes() > 0 { Ok(()) } else { diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 7e04fcff0e..e14f6a2fed 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use photocraft_algo::resample::{Resample, resize_surface, translate_surface}; +use photocraft_algo::resample::{Resample, translate_surface}; use photocraft_color::{ColorMode, PixelFormat}; use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, SmartObject, SmartSource}; use photocraft_geom::Rect; @@ -47,8 +47,8 @@ pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; use info::{exposure_text, find_element}; pub(crate) use open::{BatchOpen, batch_open_notice, batch_open_params, remember_batch_open}; pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; +pub(crate) use open::{PlaceInput, apply_place, import, import_with_options, prepare_place, tag_exr_colorspace}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; -pub(crate) use open::{import, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -171,11 +171,18 @@ pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface { /// a sparse bundle declares its canvas, and one the size of the format allows would be rendered /// in full, however little it holds. Refused past the compositor's own per-render limit. pub(crate) fn flattened_checked(doc: &Document, fmt: PixelFormat, cmd: &str) -> Result { + check_flattenable(doc, cmd)?; + Ok(flattened(doc, fmt)) +} + +/// The refusal [`flattened_checked`] gives a canvas past the compositor's per-render limit, for a +/// caller that flattens `doc` some other way (a place job, which can be cancelled while it does). +pub(crate) fn check_flattenable(doc: &Document, cmd: &str) -> Result<()> { let b = doc.bounds(); if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("the document's {}×{} canvas is too large to flatten", b.width(), b.height()) }); } - Ok(flattened(doc, fmt)) + Ok(()) } fn new_guide_layout(s: &mut Session, p: &Value) -> Result { @@ -408,6 +415,10 @@ pub fn specs() -> Vec { #[path = "file_cmds/tests.rs"] mod tests; +#[cfg(test)] +#[path = "file_cmds/place_job_tests.rs"] +mod place_job_tests; + #[cfg(test)] mod aces_exr_tests { use super::*; diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 8c10907f5e..cb05f39483 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -1,8 +1,9 @@ use super::paths::{file_name, read_file, stem, template_name}; use super::{ - Affine, Arc, Document, EngineError, Layer, LayerContent, LayerId, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, flattened_checked, - json, resize_surface, str_param, translate_surface, + Affine, Arc, Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Resample, Result, Session, SmartObject, SmartSource, Value, f64_param, json, + str_param, translate_surface, }; +use crate::jobs::JobCtx; /// Input and successfully opened document identities of one Override Open scratch session. pub(crate) struct BatchOpen { @@ -312,45 +313,164 @@ pub const PLACE_EMBEDDED: &str = "Place Embedded"; /// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes. /// With Preferences ▸ General ▸ Always Create Smart Objects When Placing off, an embedded place /// lands as a plain pixel layer instead (an explicit `"smartObject": false` asks for one). +/// +/// The decoding and resampling are a background job when the command was started with +/// [`Session::start`] (see [`crate::jobs`]); inline otherwise. The layer is inserted by the apply +/// step on the calling thread, as one undo step. pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let frame_id = match p.get("frame") { - Some(Value::Bool(false)) => None, - Some(v) if !v.is_null() => Some(LayerId(v.as_u64().ok_or_else(|| EngineError::Other("frame must be a frame id or false".into()))?)), - _ => d.active_layer.and_then(|id| crate::frame_cmds::frame_of(&d.doc, id)), - }; - let frame = frame_id - .map(|id| { - d.doc.layer(id).and_then(|l| l.frame.clone()).map(|f| (id, f)).ok_or_else(|| EngineError::Other("the destination frame is unavailable".into())) + let input = PlaceInput::new(s, name, bytes, linked, p)?; + place_input(s, input) +} + +/// Run a place: in the background when the command was started with [`Session::start`]. +fn place_input(s: &mut Session, input: PlaceInput) -> Result { + let label = input.job_label(); + crate::jobs::run(s, &label, true, move |ctx| prepare_place(input, ctx), apply_place) +} + +/// The file a place reads. +enum PlaceFile { + /// Its contents, already read (a dropped or picked file with no path, a script's bytes). + Bytes(Vec), + /// Its path, read by [`prepare_place`]: on the job's worker when the place runs in the + /// background, so a large file doesn't hold up the window while it is read. + Path(String), +} + +/// Everything a place reads from the session, taken up front so [`prepare_place`] can run on a +/// worker that never sees the [`Session`]. +pub(crate) struct PlaceInput { + name: String, + file: PlaceFile, + linked: Option, + params: Value, + canvas: (f64, f64), + fmt: PixelFormat, + opts: photocraft_io::ImportOptions, + /// Preferences ▸ General ▸ Resize Image During Place, unless the params say otherwise. + fit: bool, + /// Preferences ▸ General ▸ Always Create Smart Objects When Placing, unless the params say + /// otherwise. A linked place is always a smart object. + smart: bool, + /// The document as the job sees it, for rendering a vector source at its placement scale. A + /// path isn't read yet, so whether it is a vector source is decided once it is. + doc: Option>, + /// The frame the image goes into: the active layer's, `"frame"` in the params, or none + /// (`"frame": false`). Its image renders through a smart object. + frame: Option<(LayerId, photocraft_doc::Frame)>, +} + +impl PlaceInput { + pub(crate) fn new(s: &Session, name: &str, bytes: Vec, linked: Option, p: &Value) -> Result { + Self::with_file(s, name, PlaceFile::Bytes(bytes), linked, p) + } + + fn with_file(s: &Session, name: &str, file: PlaceFile, linked: Option, p: &Value) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let prefs = s.prefs(); + let frame_id = match p.get("frame") { + Some(Value::Bool(false)) => None, + Some(v) if !v.is_null() => Some(LayerId(v.as_u64().ok_or_else(|| EngineError::Other("frame must be a frame id or false".into()))?)), + _ => d.active_layer.and_then(|id| crate::frame_cmds::frame_of(&d.doc, id)), + }; + let frame = frame_id + .map(|id| { + d.doc + .layer(id) + .and_then(|l| l.frame.as_deref().cloned()) + .map(|f| (id, f)) + .ok_or_else(|| EngineError::Other("the destination frame is unavailable".into())) + }) + .transpose()?; + let canvas = frame.as_ref().map_or((d.doc.size.width as f64, d.doc.size.height as f64), |(_, f)| { + let [a, b, c, d, _, _] = f.transform.m; + (a.hypot(b), c.hypot(d)) + }); + let approve = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; + let mut opts = crate::jobs::import_options(prefs); + opts.psd_high_memory_approved = approve; + opts.psd_approved_estimate = None; + Ok(Self { + name: name.to_string(), + canvas, + fmt: d.doc.pixel_format(), + opts, + // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). + fit: p.get("fit").and_then(Value::as_bool).unwrap_or(prefs.general.resize_image_during_place), + // Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, + // an embedded place lands as a plain pixel layer. + smart: linked.is_some() || p.get("smartObject").and_then(Value::as_bool).unwrap_or(prefs.general.always_create_smart_objects_when_placing), + doc: match &file { + PlaceFile::Bytes(bytes) => (frame.is_some() || photocraft_io::is_svg_input(name, bytes)).then(|| Arc::clone(&d.doc)), + PlaceFile::Path(_) => Some(Arc::clone(&d.doc)), + }, + frame, + params: p.clone(), + file, + linked, }) - .transpose()?; - let (cw, ch) = frame.as_ref().map_or((d.doc.size.width as f64, d.doc.size.height as f64), |(_, f)| { - let [a, b, c, d, _, _] = f.transform.m; - (a.hypot(b), c.hypot(d)) - }); - let fmt = d.doc.pixel_format(); - let vector = photocraft_io::svg::is_svg(&bytes); - let approve = match p.get("approveHighMemory") { - None | Some(Value::Null) => false, - Some(Value::Bool(b)) => *b, - Some(_) => { - return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + + /// The job's name in the progress UI. + pub(crate) fn job_label(&self) -> String { + format!("Placing {}", file_name(&self.name)) + } + + /// The command this place stands for. + pub(crate) fn command(&self) -> &'static str { + if self.linked.is_some() { "file.placeLinked" } else { "file.placeEmbedded" } + } +} + +/// A place decoded, flattened and fitted, ready to be inserted by [`apply_place`]. +pub(crate) struct Prepared { + content: LayerContent, + layer_name: String, + /// The history step's name. + label: &'static str, + scale: f64, + bounds: [f64; 4], + smart: bool, + /// The frame the layer fills, if any (it becomes the frame's content instead of a new layer). + frame: Option, +} + +/// The heavy part of a place: read (a path), decode, flatten, resize and translate (and render a +/// vector source at its placement scale). Touches no [`Session`], so it can run on a worker; `ctx` +/// reports progress and stops it between stages when cancelled. +pub(crate) fn prepare_place(input: PlaceInput, ctx: &JobCtx) -> Result { + let PlaceInput { name, file, linked, params: p, canvas: (cw, ch), fmt, opts, fit, smart, doc, frame } = input; + let bytes = match file { + PlaceFile::Bytes(bytes) => bytes, + PlaceFile::Path(path) => { + ctx.progress(0.0, "Reading"); + let bytes = read_file(&path)?; + ctx.check()?; + bytes } }; - let mut opts = crate::jobs::import_options(s.prefs()); - opts.psd_high_memory_approved = approve; - opts.psd_approved_estimate = None; - let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| crate::error::io_open_error(name, e))?; + // Refuse before decoding if the operation cannot fit. + crate::memory_gate::ensure(&format!("place \u{201c}{}\u{201d}", file_name(&name)), crate::memory_gate::place_need(&bytes, fmt.bytes_per_pixel()))?; + let doc = doc.filter(|_| frame.is_some() || photocraft_io::is_svg_input(&name, &bytes)); + let imported = ctx.stage(0.0, 0.6, "Decoding", |ctl| photocraft_io::import_with_options_and_interrupt(&name, &bytes, &opts, ctl)).map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => crate::error::io_open_error(&name, e), + })?; if imported.preview_only { return Err(EngineError::Other(format!( "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } + ctx.check()?; let src = imported.document; let (w, h) = (src.size.width as f64, src.size.height as f64); - // The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default). - let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(s.prefs().general.resize_image_during_place); - let scale = match f64_param(p, "scale") { + let scale = match f64_param(&p, "scale") { Some(k) => (k / 100.0).max(1e-4), None if frame.is_some() && fit => (cw / w).max(ch / h), None if fit && (w > cw || h > ch) => (cw / w).min(ch / h), @@ -364,14 +484,25 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option 1e-9 { - px = resize_surface(&px, scale, scale, Resample::Bicubic); + ctx.progress(0.7, "Resizing"); + px = ctx + .stage(0.7, 0.9, "Resizing", |ctl| photocraft_algo::resample::resize_surface_with_interrupt(&px, scale, scale, Resample::Bicubic, ctl)) + .map_err(|_| EngineError::Cancelled)?; + ctx.check()?; } Some(translate_surface(&px, dx as i32, dy as i32)) } else { None }; + drop(src); let placement = frame .as_ref() .map_or(Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, |(_, f)| f.transform.mul(&Affine { m: [scale / cw, 0.0, 0.0, scale / ch, dx / cw, dy / ch] })); @@ -382,45 +513,50 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option SmartSource::Linked { path }, - None => SmartSource::Embedded { file_name: file_name(name), bytes: Arc::new(bytes) }, - }; - let layer_name = stem(name); - let label = if matches!(&source, SmartSource::Linked { .. }) { "Place Linked" } else { PLACE_EMBEDDED }; - let id = s.edit(label, |doc, active| { - let layer = if smart { - let mut so = SmartObject::new(source, placement, px); - // A vector source (SVG) renders at its placement scale instead of as a resampled raster. - if (vector || frame.is_some()) - && let Some(sharp) = crate::smart_cmds::render(doc, &so)? - { + // A frame's image is always a smart object. + let smart = smart || frame.is_some(); + let label = if linked.is_some() { "Place Linked" } else { PLACE_EMBEDDED }; + let content = if smart { + let source = match linked { + Some(path) => SmartSource::Linked { path }, + None => SmartSource::Embedded { file_name: file_name(&name), bytes: Arc::new(bytes) }, + }; + let mut so = SmartObject::new(source, placement, px); + // A vector source (SVG) and a frame's image render at their placement scale instead of as + // a resampled raster. + if let Some(doc) = doc { + ctx.progress(0.9, "Rendering"); + ctx.check()?; + if let Some(sharp) = crate::smart_cmds::render(&doc, &so)? { so.cache = Some(sharp); } - if so.cache.is_none() { - return Err(EngineError::Other(format!("{name}: the placed image could not be rendered"))); - } - Layer::new(layer_name, LayerContent::Smart(so)) - } else { - let px = px.ok_or_else(|| EngineError::Other(format!("{name}: a frame's image must be a smart object")))?; - Layer::new(layer_name, LayerContent::Raster(px)) - }; - let id = layer.id; - if let Some((frame_id, _)) = &frame { - crate::frame_cmds::put_content(doc, *frame_id, layer)?; - *active = Some(*frame_id); + } + if so.cache.is_none() { + return Err(EngineError::Other(format!("{name}: the placed image could not be rendered"))); + } + LayerContent::Smart(so) + } else { + LayerContent::Raster(px.ok_or_else(|| EngineError::Other(format!("{name}: a frame's image must be a smart object")))?) + }; + Ok(Prepared { content, layer_name: stem(&name), label, scale, bounds, smart, frame: frame.map(|(id, _)| id) }) +} + +/// Insert the prepared layer as one undo step and report the place. Runs on the session's thread. +pub(crate) fn apply_place(s: &mut Session, p: Prepared) -> Result { + let Prepared { content, layer_name, label, scale, bounds, smart, frame } = p; + let layer = Layer::new(layer_name, content); + let id = layer.id; + s.edit(label, move |doc, active| { + if let Some(frame_id) = frame { + crate::frame_cmds::put_content(doc, frame_id, layer)?; + *active = Some(frame_id); } else { crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); *active = Some(id); } - Ok(id) + Ok(()) })?; - Ok(json!({"layer": id.0, "frame": frame.as_ref().map(|(id,_)|id.0), "scale": scale * 100.0, "bounds": bounds, "smartObject": smart})) + Ok(json!({"layer": id.0, "frame": frame.map(|f| f.0), "scale": scale * 100.0, "bounds": bounds, "smartObject": smart})) } pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { @@ -429,7 +565,6 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected boolean for approveHighMemory".into() }); } let path = str_param(p, "path", cmd)?.to_string(); - let bytes = read_file(&path)?; // The link is stored absolute: a relative one would later be taken from the document's // folder (`doc_path`), not the working directory the path was given in. let link = linked.then(|| { @@ -439,5 +574,7 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result { path.clone() } }); - place_bytes(s, &path, bytes, link, p) + // The file is read by the place itself, on the job's worker when it runs in the background. + let input = PlaceInput::with_file(s, &path, PlaceFile::Path(path.clone()), link, p)?; + place_input(s, input) } diff --git a/crates/engine/src/cmd/file_cmds/place_job_tests.rs b/crates/engine/src/cmd/file_cmds/place_job_tests.rs new file mode 100644 index 0000000000..f174a54f50 --- /dev/null +++ b/crates/engine/src/cmd/file_cmds/place_job_tests.rs @@ -0,0 +1,262 @@ +//! Place as a background job (#10031): the decode, flatten and resize run on a worker and the +//! layer is inserted when the job is applied. Inline callers see no difference. + +use std::time::{Duration, Instant}; + +use super::*; +use crate::jobs::{JobEvent, JobId, JobOutcome, Started}; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("photocraft-place-job-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.to_string_lossy().into_owned() +} + +fn session(w: u32, h: u32, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); + s +} + +/// A small RGB gradient PNG, so a wrong pixel anywhere shows. +fn gradient_png(w: u32, h: u32) -> Vec { + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) % 256) as u8])).collect(); + let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +fn job(r: Started) -> JobId { + match r { + Started::Job(id) => id, + Started::Done(v) => panic!("expected a background job, ran inline: {v}"), + } +} + +/// Poll until job `id` ends (or 60 s pass); returns its event. +fn wait_event(s: &mut Session, id: JobId) -> JobEvent { + let t = Instant::now(); + loop { + if let Some(e) = s.poll_jobs().into_iter().find(|e| e.id == id) { + return e; + } + assert!(t.elapsed() < Duration::from_secs(60), "job {id:?} did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// The active layer's pixels over the canvas and its content. +fn layer_pixels(s: &Session) -> Vec { + let d = s.active().unwrap(); + let surface = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + surface.read_region(d.doc.bounds().union(&surface.content_bounds())) +} + +fn canvas_pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px.clone() +} + +fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +/// The place a job does is the place the command does inline: the same result, layer, pixels and +/// one history step, at every depth, for a smart object and for a pixel layer. +#[test] +fn a_place_job_equals_the_inline_place() { + let dir = tmp("equal"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(160, 80)).unwrap(); + for depth in [8, 16, 32] { + for smart in [true, false] { + let params = json!({"path": path, "smartObject": smart}); + let mut inline = session(100, 100, depth); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = session(100, 100, depth); + let before = steps(&s); + let id = job(s.start("file.placeEmbedded", params).unwrap()); + assert_eq!(steps(&s), before, "nothing is inserted before the job is applied"); + let e = wait_event(&mut s, id); + let JobOutcome::Done(got) = &e.outcome else { panic!("{depth} {smart}: {e:?}") }; + assert_eq!(e.command, "file.placeEmbedded"); + assert_eq!(e.label, "Placing grad.png"); + same_but_layer(got, &want); + assert_eq!(steps(&s), before + 1, "one undo step"); + assert_eq!(s.active().unwrap().history.undo_label(), Some(PLACE_EMBEDDED)); + assert_eq!(layer_pixels(&s), layer_pixels(&inline), "layer pixels at {depth}-bit, smart {smart}"); + assert_eq!(canvas_pixels(&s), canvas_pixels(&inline), "composite at {depth}-bit, smart {smart}"); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } + } +} + +/// Place Linked as a job keeps the link and its history label. +#[test] +fn a_linked_place_job_keeps_the_link() { + let dir = tmp("linked"); + let path = join(&dir, "grad.png"); + std::fs::write(&path, gradient_png(40, 40)).unwrap(); + let mut s = session(100, 100, 8); + let id = s.start("file.placeLinked", json!({"path": path})).map(job).unwrap(); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + assert!(matches!(&l.content, LayerContent::Smart(so) if matches!(&so.source, SmartSource::Linked { path: p } if *p == path))); + assert_eq!(d.history.undo_label(), Some("Place Linked")); +} + +/// Bytes with no path (a web or dropped file) start the same job through `start_place_bytes`. +#[test] +fn start_place_bytes_matches_place_bytes() { + let bytes = gradient_png(160, 80); + let mut inline = session(100, 100, 16); + let want = place_bytes(&mut inline, "drop.png", bytes.clone(), None, &json!({})).unwrap(); + + let mut s = session(100, 100, 16); + let id = job(s.start_place_bytes("drop.png", bytes.clone(), None, json!({})).unwrap()); + // The document is locked meanwhile: another place (or edit) is refused with the job's name. + let refused = s.start_place_bytes("again.png", bytes, None, json!({})).unwrap_err().to_string(); + assert!(refused.contains("Placing drop.png"), "{refused}"); + assert!(s.execute("layer.new.layer", json!({})).is_err()); + assert!(s.execute("jobs.list", json!({})).is_ok(), "queries still run"); + let e = wait_event(&mut s, id); + assert_eq!(e.command, "file.placeEmbedded"); + let JobOutcome::Done(got) = &e.outcome else { panic!("{e:?}") }; + same_but_layer(got, &want); + assert_eq!(got["layer"], json!(s.active().unwrap().active_layer.unwrap().0), "the result names the placed layer"); + assert_eq!(layer_pixels(&s), layer_pixels(&inline)); + assert_eq!(steps(&s), steps(&inline)); +} + +/// Assert a place result equals `want` except for its `layer`: layer ids come from a counter +/// shared by every session, so two sessions never hand out the same one. +fn same_but_layer(got: &Value, want: &Value) { + let strip = |v: &Value| { + let mut v = v.clone(); + v.as_object_mut().map(|o| o.remove("layer")); + v + }; + assert!(got["layer"].is_u64(), "{got}"); + assert_eq!(strip(got), strip(want)); +} + +/// Cancelling stops the place: nothing is inserted and the document is the very same one. +#[test] +fn cancelling_a_place_job_leaves_the_document_unchanged() { + let mut s = session(100, 100, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + assert!(s.cancel_job(id)); + s.join_cancelled_jobs(); + assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); + assert!(!s.active().unwrap().history.can_redo()); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); + assert!(s.active_job().is_none(), "the document is unlocked"); +} + +/// An edit made while the job runs wins: applying fails and no layer is placed. +#[test] +fn an_edit_during_a_place_job_discards_the_place() { + let mut s = session(100, 100, 8); + let id = job(s.start_place_bytes("drop.png", gradient_png(160, 80), None, json!({})).unwrap()); + // Commands are refused while the job runs; a direct edit (the shell's, say) is not. + s.edit("Poke", |doc, _| { + doc.name = "poked".into(); + Ok(()) + }) + .unwrap(); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("Placing drop.png") && err.contains("document changed"), "{err}"); + let d = s.active().unwrap(); + assert_eq!(d.doc.name, "poked", "the edit is kept"); + assert_eq!(d.doc.layers.len(), 1, "nothing was placed"); + assert_eq!(d.history.undo_label(), Some("Poke")); +} + +/// Hostile input as a job is an error, never a panic, and the document is left alone. +#[test] +fn bad_input_as_a_place_job_fails_without_panicking() { + let dir = tmp("bad"); + let good = gradient_png(32, 32); + let empty = join(&dir, "empty.png"); + std::fs::write(&empty, b"").unwrap(); + let truncated = join(&dir, "truncated.png"); + std::fs::write(&truncated, good.get(..good.len() / 2).unwrap()).unwrap(); + let garbage = join(&dir, "garbage.png"); + std::fs::write(&garbage, [0x89u8, b'P', b'N', b'G', 0xff, 0x00, 0x13, 0x37, 0x42]).unwrap(); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + for path in [&empty, &truncated, &garbage] { + let id = job(s.start("file.placeEmbedded", json!({"path": path})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{path}: {e:?}") }; + assert!(err.contains("png"), "the file is named: {err}"); + } + for bytes in [Vec::new(), good.get(..10).unwrap().to_vec(), vec![0u8; 3]] { + let id = job(s.start_place_bytes("bad.png", bytes, None, json!({})).unwrap()); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); + } + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc), "a failed place changes nothing"); + assert_eq!(steps(&s), before); + // Params of the wrong type fall back to the defaults or are refused, but never panic. + let params = json!({"scale": "big", "center": "middle", "fit": "yes", "smartObject": 3}); + if let Ok(Started::Job(id)) = s.start_place_bytes("ok.png", good, None, params) { + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); + assert!(s.undo()); + } + assert_eq!(steps(&s), before); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + // Inline, a missing document is the same error as before. + let mut none = Session::new(); + assert!(matches!(place_bytes(&mut none, "a.png", Vec::new(), None, &json!({})), Err(EngineError::NoDocument))); + assert!(matches!(none.start_place_bytes("a.png", Vec::new(), None, json!({})), Err(EngineError::NoDocument))); +} + +/// A place from a path reads the file in the job, not in the command that starts it: a large file +/// doesn't hold up the window while it is read. A file that can't be read fails the job with the +/// error the inline place gives, and changes nothing. +#[test] +fn a_place_job_reads_its_file_on_the_worker() { + let dir = tmp("read"); + let missing = join(&dir, "missing.png"); + let mut inline = session(64, 64, 8); + let want = inline.execute("file.placeEmbedded", json!({"path": missing})).unwrap_err().to_string(); + assert!(want.contains(&missing), "{want}"); + + let mut s = session(64, 64, 8); + let (doc, before) = (s.active().unwrap().doc.clone(), steps(&s)); + let id = job(s.start("file.placeEmbedded", json!({"path": missing})).expect("the job starts before the file is read")); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert_eq!(err, &want); + assert!(Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(steps(&s), before); +} + +/// A placed file whose canvas is past the compositor's limit is refused with the same error the +/// place gave before it ran as a job: bad params naming the canvas. +#[test] +fn a_canvas_too_large_to_flatten_is_refused_as_before() { + let huge = Document::new("huge", photocraft_doc::Size::new(40_000, 30_000), ColorMode::Rgb, photocraft_color::SampleType::U8); + let bytes = photocraft_format::save_to_bytes(&huge, &Default::default()).unwrap(); + let mut s = session(100, 100, 8); + let before = steps(&s); + match place_bytes(&mut s, "huge.pcraft", bytes.clone(), None, &json!({})) { + Err(EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, "file.place"); + assert_eq!(msg, "the document's 40000×30000 canvas is too large to flatten"); + } + other => panic!("{other:?}"), + } + let id = job(s.start_place_bytes("huge.pcraft", bytes, None, json!({})).unwrap()); + let e = wait_event(&mut s, id); + let JobOutcome::Failed(err) = &e.outcome else { panic!("{e:?}") }; + assert!(err.contains("40000×30000 canvas is too large to flatten"), "{err}"); + assert_eq!(steps(&s), before, "nothing was placed"); +} diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 152f4bdccf..ffe55a995c 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -1,9 +1,10 @@ //! Image menu: Image Size, Canvas Size, Crop, Trim, Mode (colour model and //! bit depth) conversions, Duplicate. +use std::collections::HashSet; use std::convert::Infallible; -use photocraft_algo::resample::{Resample, crop_surface, resize_surface_in_canvas, translate_surface}; +use photocraft_algo::resample::{Resample, crop_surface, largest_resize_rect, resize_surface_in_canvas, scaled_rect, translate_surface}; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::{Document, Effect, Effects, FxPaint, Layer, LayerContent, LayerId, Size}; use photocraft_geom::Rect; @@ -11,6 +12,7 @@ use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::commands::CommandSpec; +use crate::document_preset_cmds::MAX_RESOLUTION; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -235,15 +237,27 @@ fn image_size(s: &mut Session, p: &Value) -> Result { pw = pw.or(Some(ow as f64 * pc / 100.0)); ph = ph.or(Some(oh as f64 * pc / 100.0)); } + // The document range File › New and the dialog's field allow; outside it, refuse rather than + // clamp (#9260: 1e39 became inf as f32, then silently 10000 ppi). The document's own + // resolution always passes: an imported file may have one outside the range, and the dialog + // sends it back unchanged (exactly: a value that only rounds to it as f32 is refused). + let own_dpi = f64::from(d.doc.resolution_dpi); + let dpi = match p.get("resolution") { + None | Some(Value::Null) => None, + Some(v) => { + let r = v.as_f64().filter(|&r| r.is_finite() && ((1.0..=MAX_RESOLUTION).contains(&r) || r == own_dpi)); + Some(r.ok_or_else(|| bad("image.imageSize", format!("`resolution` = {v} must be a number of pixels per inch from 1 to {MAX_RESOLUTION}")))?) + } + }; // `keepPrintSize`: only the resolution given, the pixels follow it (Photoshop's Image Size - // with Resample on, as an action records it). + // with Resample on, as an action records it). An unchanged resolution keeps the pixels. if p.get("keepPrintSize").and_then(Value::as_bool) == Some(true) && pw.is_none() && ph.is_none() - && let Some(r) = p.get("resolution").and_then(Value::as_f64) + && let Some(r) = dpi + && r != own_dpi { - let r = r.clamp(1.0, 10_000.0); - let old = f64::from(d.doc.resolution_dpi).max(1.0); + let old = own_dpi.max(1.0); pw = Some(ow as f64 * r / old); ph = Some(oh as f64 * r / old); } @@ -260,9 +274,10 @@ fn image_size(s: &mut Session, p: &Value) -> Result { if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } - let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { + // Before the sampler map or the document change: a resize that doesn't fit would trap on the web (#10024). + crate::memory_gate::ensure(&format!("resize the image to {nw} x {nh} px"), crate::memory_gate::image_size_need(&d.doc, nw, nh))?; // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); s.set_sampler_map(photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); @@ -270,7 +285,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { s.edit("Image Size", |doc, _| { if let Some(r) = dpi { // Type keeps its pixel size; resampling below then scales it with the pixels. - crate::canvas_geom::set_resolution(doc, r.clamp(1.0, 10_000.0)); + crate::canvas_geom::set_resolution(doc, r as f32); } let filter = match resample { Method::None => return Ok(()), @@ -278,8 +293,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc_with_styles(doc, nw, nh, filter, scale_styles); - Ok(()) + resample_doc_with_styles(doc, "image.imageSize", nw, nh, filter, scale_styles) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; @@ -314,18 +328,20 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution -/// mode. Callers check [`check_resample_budget`] first. -pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { - resample_doc_with_styles(doc, nw, nh, filter, true); +/// mode. Callers check [`check_resample_budget`] for the new canvas first; the pixels outside the +/// canvas are scaled too, so a surface they would grow past the budget is refused here (#9212). +pub(crate) fn resample_doc(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample) -> Result<()> { + resample_doc_with_styles(doc, cmd, nw, nh, filter, true) } /// Resize document content while optionally retaining layer-effect sizes in pixels. -fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { +fn resample_doc_with_styles(doc: &mut Document, cmd: &str, nw: u32, nh: u32, filter: Resample, scale_styles: bool) -> Result<()> { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { - return; + return Ok(()); } + check_hidden_resample_budget(cmd, doc, sx, sy, filter)?; // Content that reaches the canvas edge keeps it: a Background stays opaque to the border. let canvas = Rect::from_xywh(0, 0, ow, oh); let taken = crate::smart_cmds::take_caches(&mut doc.layers); @@ -356,6 +372,76 @@ fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resamp doc.size = Size::new(nw, nh); crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); let Ok(()) = crate::smart_cmds::restore_unrendered(doc, taken, |c| Ok::<_, Infallible>(resize_surface_in_canvas(c, sx, sy, filter, canvas))); + Ok(()) +} + +/// [`check_resample_budget`] for the largest surface [`resample_doc`] stores while scaling by +/// `sx` x `sy` with `filter` (masks, channels and the selection with Bilinear): each keeps its +/// pixels outside the canvas (a crop that didn't delete them, a layer moved off the canvas), and +/// those grow with the scale like the rest. A bicubic shrink also stores the image between its +/// two passes, one axis still at its old size ([`largest_resize_rect`]). The refusal suggests +/// Delete Cropped Pixels only when the surfaces over the budget are all ones it clips +/// ([`delete_cropped_pixels`]). +fn check_hidden_resample_budget(cmd: &str, doc: &mut Document, sx: f64, sy: f64, filter: Resample) -> Result<()> { + let new_canvas = scaled_rect(doc.bounds(), sx, sy); + // The surfaces Delete Cropped Pixels clips; masks, channels, the selection, type, shapes and + // smart objects keep their pixels outside the crop. + let mut clipped: HashSet<*const Surface> = HashSet::new(); + for_each_layer(&mut doc.layers, &mut |l| { + clipped.extend(l.fill_cache.as_ref().map(|fc| std::ptr::from_ref(&fc.surface))); + if let Some(v) = &l.video { + clipped.extend(v.frames.iter().chain(v.original_frames.iter().flatten()).map(std::ptr::from_ref)); + } + if let LayerContent::Raster(s) = &l.content { + clipped.insert(std::ptr::from_ref(s)); + } + }); + let bytes = |(w, h, bpp): (u32, u32, u64)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + // The largest surface by bytes, and the largest one Delete Cropped Pixels doesn't clip: its + // width, height and bytes per pixel (a mask has one channel, the layers the document's). + let (mut largest, mut largest_kept) = ((0u32, 0u32, 1u64), (0u32, 0u32, 1u64)); + let mut visit = |surf: &Surface, filter: Resample| { + let r = largest_resize_rect(surf.content_bounds(), sx, sy, filter); + // What stays within the new canvas is already budgeted. + if r.is_empty() || new_canvas.union(&r) == new_canvas { + return; + } + let this = (r.width(), r.height(), surf.format().bytes_per_pixel().max(1) as u64); + if bytes(this) > bytes(largest) { + largest = this; + } + if !clipped.contains(&std::ptr::from_ref(surf)) && bytes(this) > bytes(largest_kept) { + largest_kept = this; + } + }; + // The surfaces `resample_doc` scales, smart object caches included. + for_each_surface(&mut doc.layers, true, &mut |surf, is_mask| visit(surf, if is_mask { Resample::Bilinear } else { filter })); + for ch in doc.channels.iter().chain(doc.quick_mask.as_ref()) { + visit(&ch.surface, Resample::Bilinear); + } + if let Some(sel) = &doc.selection { + visit(sel, Resample::Bilinear); + } + let (kept, (w, h, bpp)) = if bytes(largest_kept) > MAX_RESAMPLE_BYTES { + (true, largest_kept) + } else if bytes(largest) > MAX_RESAMPLE_BYTES { + (false, largest) + } else { + return Ok(()); + }; + let hint = if kept { + "choose a smaller size (layer masks, channels, the quick mask, the selection, type, shapes and smart objects keep theirs through Crop with Delete Cropped Pixels)" + } else { + "choose a smaller size, or delete them first (Crop with Delete Cropped Pixels)" + }; + let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000; + Err(bad( + cmd, + format!( + "{w} x {h} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. The pixels outside the canvas are resampled too: {hint}", + u64::from(w).saturating_mul(u64::from(h)) / 1_000_000 + ), + )) } fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -726,8 +812,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; } doc.selection = None; - target.apply(doc); - Ok(()) + target.apply(doc) })?; Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }))) } @@ -742,15 +827,16 @@ struct CropTarget { impl CropTarget { /// Applies the target to a document that has just been cropped (inside the crop's history step). - fn apply(&self, doc: &mut Document) { + fn apply(&self, doc: &mut Document) -> Result<()> { if let Some((w, h)) = self.size { // As for Image Size: an Indexed Color document holds only its colour table's colours (#6019). let filter = if doc.mode == ColorMode::Indexed { Resample::Nearest } else { self.filter }; - resample_doc(doc, w, h, filter); + resample_doc(doc, "image.crop", w, h, filter)?; } if let Some(r) = self.resolution { doc.resolution_dpi = r; } + Ok(()) } /// Where Color Sampler points go: shifted by the crop's origin, then scaled with the pixels @@ -935,8 +1021,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar crate::crop_fill::fill_uncovered(doc, *active, &coverage)?; } doc.selection = None; - crop_target.apply(doc); - Ok(()) + crop_target.apply(doc) }) })?; Ok(crop_result(s, json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "angle": angle }))) diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fc4ce0bcf9..96e0637994 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -234,6 +234,48 @@ fn resolution_only_change() { assert_eq!(r, json!({"width": 40, "height": 20})); } +/// #9260: a resolution outside the document range (1..=30000 ppi, as File › New and the +/// dialog's field) is refused and leaves the document as it was, instead of being clamped; +/// one inside it, also above 10000, is set as given. +#[test] +fn image_size_refuses_a_resolution_out_of_range() { + for extra in [json!({"resample": "none"}), json!({"resample": "bicubic"}), json!({"resample": "bicubic", "keepPrintSize": true})] { + for r in [json!(1e39), json!(-5), json!(0), json!(0.5), json!(30_001), json!("300")] { + let mut s = session(); + let (size, dpi, past) = (doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()); + let mut p = extra.clone(); + p["resolution"] = r; + assert!(s.execute("image.imageSize", p.clone()).is_err(), "{p}"); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi, s.active().unwrap().history.past_len()), (size, dpi, past), "{p}: nothing changed"); + } + } + let mut s = session(); + s.execute("image.imageSize", json!({"resolution": 20_000, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(40, 20), 20_000.0)); + s.execute("image.imageSize", json!({"resolution": null, "resample": "none"})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 20_000.0, "null leaves the resolution as it is"); + // An imported document's own resolution outside the range (a PNG pHYs of 1 px/m is + // 0.0254 ppi) still resizes: the Image Size dialog sends it back unchanged on OK. + for own in [0.0254_f32, 40_000.0] { + let mut s = session(); + s.edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + s.execute("image.imageSize", json!({"width": 80, "height": 40, "resolution": f64::from(own), "resample": "bicubic"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}"); + assert!(s.execute("image.imageSize", json!({"resolution": 0.03, "resample": "none"})).is_err(), "{own}: another value is still checked"); + // Only the exact value: one that merely rounds to it as f32 is refused. + let near = if own < 1.0 { f64::from(own) * (1.0 + 1e-9) } else { f64::from(own) + 0.001 }; + assert_eq!(near as f32, own); + assert!(s.execute("image.imageSize", json!({"resolution": near, "resample": "none"})).is_err(), "{own}: {near}"); + // keepPrintSize with the unchanged resolution keeps the pixels. + s.execute("image.imageSize", json!({"resolution": f64::from(own), "resample": "bicubic", "keepPrintSize": true})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(80, 40), own), "{own}: keepPrintSize"); + } +} + /// Type keeps its pixel size across a resolution change: resampled 2x at twice the ppi it is /// twice as tall (not 4x), with Resample off it stays, and a later re-render doesn't jump. #[test] @@ -825,6 +867,79 @@ fn crop_target_accepts_whole_number_floats() { assert_eq!(doc(&s).size, Size::new(40, 20)); } +/// #9212: a non-deleting crop keeps the pixels outside the frame, and W x H resamples them +/// too. A 20 px frame of a 2000 px Background resampled to 2000 px scales its hidden pixels +/// x100, to 200000 px a side (160 GB at 8 bits, past the 8 GiB budget): refused before +/// allocating, like a target size past the budget. +#[test] +fn crop_target_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [500, 700]})).unwrap(); + let symmetry = s.active().unwrap().symmetry.clone(); + let past = s.active().unwrap().history.past_len(); + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "deleteCroppedPixels": false, "targetWidth": 2000, "targetHeight": 2000}); + for angle in [None, Some(90)] { + let mut p = p.clone(); + if let Some(a) = angle { + p["angle"] = json!(a); + } + let err = s.execute("image.crop", p).unwrap_err().to_string(); + // Since the crop turns the Background into Layer 0 (#10867), the turned layer's + // bounds are 2001 px a side, resampled x100 to 200100. + let want = if angle.is_some() { "200100 x 200100 px is 40040 megapixels" } else { "200000 x 200000 px is 40000 megapixels" }; + assert!(err.contains(want) && err.contains("Delete Cropped Pixels"), "{angle:?}: {err}"); + assert_eq!(doc(&s).size, Size::new(2000, 2000), "{angle:?}: the document is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{angle:?}: no history step"); + // The turned crop rotates first: the painting symmetry turns back with the document. + assert_eq!(s.active().unwrap().symmetry, symmetry, "{angle:?}: the painting symmetry stays"); + } + // Deleting the cropped pixels leaves only the frame to resample. + p["deleteCroppedPixels"] = json!(true); + s.execute("image.crop", p).unwrap(); + assert_eq!(doc(&s).size, Size::new(2000, 2000)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); +} + +/// #9212: Image Size resamples the pixels outside the canvas as well. After a non-deleting +/// crop to 10 px, 20000 px (400 MP, within the budget) scales the hidden 100 px Background x2000. +#[test] +fn image_size_budgets_the_hidden_pixels_it_resamples() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + // The hidden pixels are a pixel layer's: Delete Cropped Pixels would clear them. + assert!(err.contains("or delete them first (Crop with Delete Cropped Pixels)"), "{err}"); + assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), (Size::new(10, 10), past)); + // Without resampling only the resolution changes. + s.execute("image.imageSize", json!({"resolution": 300, "resample": "none"})).unwrap(); + assert_eq!((doc(&s).size, doc(&s).resolution_dpi), (Size::new(10, 10), 300.0)); +} + +/// Delete Cropped Pixels clips pixel layers only: an alpha channel keeps its pixels outside the +/// crop, so the refusal doesn't send the user to an option that can't clear it. +#[test] +fn hidden_resample_budget_names_what_delete_cropped_pixels_keeps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.edit("channel", |doc, _| { + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, false); + let mut ch = Surface::new(fmt); + ch.write_region(Rect::new(0, 0, 100, 100), &[1.0; 100 * 100]); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", ch)); + Ok(()) + }) + .unwrap(); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": true})).unwrap(); + let err = s.execute("image.imageSize", json!({"width": 20000, "height": 20000})).unwrap_err().to_string(); + assert!(err.contains("200000 x 200000 px is 40000 megapixels"), "{err}"); + assert!(err.contains("channels") && !err.contains("delete them first"), "{err}"); + assert_eq!(doc(&s).size, Size::new(10, 10)); +} + #[test] fn crop_target_rejects_bad_params_without_changing_the_document() { let rect = json!({"x": 0, "y": 0, "width": 10, "height": 10}); @@ -973,6 +1088,24 @@ fn duplicate_document() { assert!(doc(&s).name.ends_with("copy")); } +/// #9212: a bicubic shrink stores the image between its two passes with one axis at its old +/// size. A Background whose kept pixels reach (69999, 69999), halved from 200 to 100 px, ends at +/// about 35000 x 35000 px (4.9 GB, within the 8 GiB budget) but passes through about +/// 35000 x 70000 px (9.8 GB): refused before allocating. Bilinear has one pass and fits. The +/// check alone runs, so nothing that large is allocated either way. +#[cfg(target_pointer_width = "64")] +#[test] +fn hidden_resample_budget_counts_the_image_between_bicubic_passes() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let mut d = doc(&s).clone(); + let bg = d.background_mut().unwrap().surface_mut().unwrap(); + bg.fill_rect(Rect::new(69_999, 69_999, 70_000, 70_000), &[1.0, 0.0, 0.0, 1.0]); + let err = check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bicubic).unwrap_err().to_string(); + assert!(err.contains(" x 70000 px is "), "{err}"); + check_hidden_resample_budget("image.imageSize", &mut d, 0.5, 0.5, Resample::Bilinear).unwrap(); +} + #[test] fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs index c7acbff4a0..af70e8c9fe 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs @@ -243,7 +243,7 @@ fn refreshing_type_layers_damages_only_those_layers() { let one = LayerId(s.execute("type.create", json!({"x": 100, "y": 100, "text": "One", "size": 20})).unwrap()["layer"].as_u64().unwrap()); let two = LayerId(s.execute("type.create", json!({"x": 1000, "y": 800, "text": "Two", "size": 20})).unwrap()["layer"].as_u64().unwrap()); let before = doc(&s); - let busy = s.refresh_type_layers(&[(before.id, one)]); + let busy = s.refresh_type_layers(&[crate::TypeLayerRef { doc: before.id, layer: one, first_snapshot: false }]); assert!(busy.is_empty()); let after = doc(&s); let d = damage(&s).expect("a bounded refresh"); diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 3043f080e6..ea6504f7a8 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { } s.edit("Bitmap", |doc, active| { if (nw, nh) != (doc.size.width, doc.size.height) { - crate::image_cmds::resample_doc(doc, nw, nh, photocraft_algo::resample::Resample::Bicubic); + crate::image_cmds::resample_doc(doc, CMD, nw, nh, photocraft_algo::resample::Resample::Bicubic)?; } doc.resolution_dpi = dpi; let w = doc.size.width as usize; diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 4d3c97b02b..8f190f445c 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -452,6 +452,18 @@ fn bitmap_output_resolution_resamples_and_sets_the_resolution() { assert_eq!(first_white(doc(&s), 0, 1, 0), Some(128)); } +/// #9212: the Output resolution resamples the pixels outside the canvas too. A 10 px crop that +/// kept a 1000 px document's pixels, converted from 1 to 100 ppi, would grow them to 100000 px a +/// side (20 GB of gray and alpha at 8 bits, past the 8 GiB budget): refused before allocating. +#[test] +fn bitmap_output_resolution_budgets_the_hidden_pixels() { + let mut s = ramp(1000, 1000, 8, 1.0); + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10, "deleteCroppedPixels": false})).unwrap(); + let err = s.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 100})).unwrap_err().to_string(); + assert!(err.contains("100000 x 100000 px is 10000 megapixels"), "{err}"); + assert_eq!((doc(&s).size.width, doc(&s).mode), (10, ColorMode::Grayscale)); +} + #[test] fn bitmap_halftone_screen_is_laid_out_at_the_output_resolution() { // The same 10 lines/inch screen has twice the cell (in pixels) at twice the resolution: the diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 83317e2645..927beba121 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -70,7 +70,11 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result< surf.read_rgba_into(area, &mut px); Ok((area, px)) } - _ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())), + _ => { + // The composite allocates inside compose; report a failure at its boundary. + crate::allocation::checkpoint("sampling pixels for a selection")?; + Ok((area, photocraft_compose::render(&d.doc, area).into_px())) + } } } @@ -540,21 +544,31 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { out } +/// Pushes a pixel onto the grow frontier, reporting an allocation failure instead of aborting. +fn try_push(stack: &mut Vec, i: usize) -> Result<()> { + crate::allocation::try_push(stack, i, "growing a selection") +} + /// Grow (contiguous) / Similar (anywhere): pixels whose colour lies within /// tolerance of any selected pixel (indexed via ColorMatcher BVH). fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let tol = f(p, "tolerance", 32.0) / 255.0; let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); - let matcher = sel::ColorMatcher::new(px.iter().zip(&m).filter_map(|(q, v)| (*v >= 0.5).then_some(*q)), tol); + let matcher = + sel::ColorMatcher::new(px.iter().zip(&m).filter_map(|(q, v)| (*v >= 0.5).then_some(*q)), tol).map_err(|message| EngineError::Other(message.into()))?; if matcher.is_empty() { return Err(EngineError::Other("selection is empty".into())); } let (w, h) = (area.width() as usize, area.height() as usize); - let mut out: Vec = m.iter().map(|v| if *v >= 0.5 { 1.0 } else { 0.0 }).collect(); + let mut out = crate::allocation::filled(m.len(), 0.0f32, "growing a selection")?; + for (o, v) in out.iter_mut().zip(&m) { + *o = if *v >= 0.5 { 1.0 } else { 0.0 }; + } if contiguous { - // Frontier stack: only push selected pixels adjacent to at least one unselected pixel - let mut stack = Vec::new(); + // Frontier stack: only push selected pixels adjacent to at least one unselected pixel. + // Every push goes through `try_push` so a canvas-sized stack fails instead of aborting. + let mut stack: Vec = Vec::new(); for y in 0..h { for x in 0..w { let i = y * w + x; @@ -564,7 +578,7 @@ fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let has_unselected_neighbor = (x > 0 && out[i - 1] == 0.0) || (x + 1 < w && out[i + 1] == 0.0) || (y > 0 && out[i - w] == 0.0) || (y + 1 < h && out[i + w] == 0.0); if has_unselected_neighbor { - stack.push(i); + try_push(&mut stack, i)?; } } } @@ -574,7 +588,7 @@ fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { for j in n.into_iter().flatten() { if out[j] == 0.0 && matcher.matches(px[j]) { out[j] = 1.0; - stack.push(j); + try_push(&mut stack, j)?; } } } diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index e0e93bcd57..b4e3e2999d 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -79,6 +79,20 @@ fn selection_sampling_allocation_failure_keeps_the_selection() { assert_eq!(s.active().unwrap().history.entries(), history); } +#[test] +fn selection_sampling_all_layers_reports_a_failed_composite_and_keeps_the_selection() { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 4, "height": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + // Sample All Layers renders the composite: the failure is reported there, before the mask. + crate::allocation::fail_next_for_test(); + let err = s.execute("select.similar", json!({"sampleAllLayers": true})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for sampling pixels for a selection"), "{err}"); + assert_eq!(*s.active().unwrap().doc, *before); + assert_eq!(s.active().unwrap().history.entries(), history); +} + #[test] fn modify_rejects_oversized_canvases_before_reading_the_mask() { let mut s = Session::new(); @@ -674,6 +688,36 @@ fn grow_and_similar_multi_color_does_not_select_intermediate_hull() { assert_eq!(coverage(&s, 30, 10), 0.0, "disconnected blue rect must not be grown"); } +#[test] +fn grow_and_similar_report_a_failed_mask_allocation_and_keep_the_selection() { + for cmd in ["select.grow", "select.similar"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // The first fallible allocation samples the layer; the second is the canvas-sized result. + crate::allocation::fail_after_for_test(1); + let err = s.execute(cmd, json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{cmd}: {err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "{cmd}: the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "{cmd}: the selection must be untouched"); + } +} + +#[test] +fn grow_reports_a_failed_frontier_push_mid_traversal_and_keeps_the_selection() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + s.execute("select.rect", json!({"x": 6, "y": 6, "width": 4, "height": 4})).unwrap(); + // Two buffer allocations (sample, result), then the 12 ring pixels of the 4x4 selection seed the + // frontier; three more clean pushes happen while flood-filling outwards before the next one fails. + crate::allocation::fail_after_for_test(2 + 12 + 3); + let err = s.execute("select.grow", json!({"tolerance": 5})).unwrap_err().to_string(); + assert!(err.contains("not enough memory for growing a selection"), "{err}"); + assert_eq!(coverage(&s, 7, 7), 1.0, "the selection must be untouched"); + assert_eq!(coverage(&s, 20, 20), 0.0, "the flood fill must not have been committed"); + assert_eq!(coverage(&s, 11, 7), 0.0, "the flood fill must not have been committed"); +} + #[test] fn lasso_polygon() { let mut s = session(); diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index 7385c21da9..56e936004c 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -357,6 +357,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result 0.9, "IoU {v}"); } /// A vertical Gaussian-blurred edge at x = 40 (warm left, cool right) with the left half selected. diff --git a/crates/engine/src/cmd/type_cmds/tests.rs b/crates/engine/src/cmd/type_cmds/tests.rs index 225a20ab15..066e0f5a8a 100644 --- a/crates/engine/src/cmd/type_cmds/tests.rs +++ b/crates/engine/src/cmd/type_cmds/tests.rs @@ -104,8 +104,9 @@ fn refreshing_type_layers_takes_no_history_step() { st.saved_revision = st.revision; } let rev = s.active().unwrap().revision; - let unknown = [(doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; - assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty()); + let layers = [(doc_id, id), (doc_id, LayerId(u64::MAX)), (photocraft_doc::DocId(u64::MAX), id)]; + let layers: Vec<_> = layers.iter().map(|&(doc, layer)| crate::TypeLayerRef { doc, layer, first_snapshot: false }).collect(); + assert!(s.refresh_type_layers(&layers).is_empty()); assert!(text_layer(&s, id.0).cache.is_some()); let st = s.active().unwrap(); assert!(st.revision > rev); @@ -115,6 +116,94 @@ fn refreshing_type_layers_takes_no_history_step() { assert!(s.active().unwrap().doc.layer(id).is_none()); } +/// The first History snapshot is the document as opened, taken before a served font arrived: +/// the refresh re-renders its type layers too, so returning to it keeps the real font (#6087). +#[test] +fn refreshing_type_layers_refreshes_the_first_snapshot() { + use crate::TypeLayerRef; + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + let snap_cache = |s: &Session| match &s.active().unwrap().snapshots[0].doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.cache.is_some(), + other => panic!("{}", other.kind_name()), + }; + let open = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: false }; + let first = |doc, layer| TypeLayerRef { doc, layer, first_snapshot: true }; + // Opened with the fallback font: the first snapshot is the open document itself. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + assert!(!snap_cache(&s)); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(snap_cache(&s), "the first snapshot is refreshed with the document"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it"); + // Edited since it opened: the snapshot is refreshed on its own, the edit stays. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let layers = s.active().unwrap().doc.layers.len(); + assert!(s.refresh_type_layers(&[open(doc_id, id), first(doc_id, id), first(doc_id, LayerId(9999))]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_some()); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers); + assert_eq!(st.snapshots[0].doc.layers.len(), layers - 1, "the snapshot keeps the document as opened"); + // Each version is refreshed only for the layers picked in it: one picked in the open + // document (e.g. a PSD layer edited since, while the snapshot keeps the file's pixels) + // leaves the snapshot alone, and the other way round. + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.refresh_type_layers(&[open(doc_id, id)]).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some()); + assert!(!snap_cache(&s), "the snapshot was not picked"); + s.add_document(stale.clone(), None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.new.layer", json!({})).unwrap(); + let rev = s.active().unwrap().revision; + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s)); + assert!(text_layer(&s, id.0).cache.is_none(), "the open layer was not picked"); + assert_eq!(s.active().unwrap().revision, rev, "and the open document did not change"); + // The layer is gone from the open document: the snapshot still gets the real font. + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert!(s.active().unwrap().doc.layer(id).is_none()); + assert!(s.refresh_type_layers(&[first(doc_id, id)]).is_empty()); + assert!(snap_cache(&s), "a layer deleted since it opened is refreshed in the snapshot"); +} + +/// A layer picked in the first snapshot while the document had moved on is refreshed in the open +/// document too when, by the time of the refresh (a busy document's retry), Undo has taken it back +/// to the snapshot: the two share one document, which must not keep the fallback font. +#[test] +fn a_layer_picked_in_the_first_snapshot_refreshes_the_open_document_it_is_again() { + let mut s = session(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + let mut stale = (*s.active().unwrap().doc).clone(); + let Some(Layer { content: LayerContent::Text(t), .. }) = stale.layer_mut(id) else { panic!("not a type layer") }; + t.cache = None; + s.add_document(stale, None); + let doc_id = s.active().unwrap().doc.id; + // Picked while the layer was deleted from the open document: only in the snapshot. + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + let picked = [crate::TypeLayerRef { doc: doc_id, layer: id, first_snapshot: true }]; + assert!(s.undo()); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "Undo is back at the snapshot itself"); + let rev = st.revision; + assert!(s.refresh_type_layers(&picked).is_empty()); + assert!(text_layer(&s, id.0).cache.is_some(), "the open document has the real font"); + let st = s.active().unwrap(); + assert!(Arc::ptr_eq(&st.snapshots[0].doc, &st.doc), "and still shares it with the snapshot"); + assert!(st.revision > rev); +} + #[test] fn create_renders_and_is_undoable() { let mut s = session(); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a8f50c3084..e10f5be984 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -82,6 +82,11 @@ impl DocState { let mut history = History::default(); let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }; history.set_current_layers(target.clone()); + // Spot channels open visible, printing over the composite; alpha channels open hidden. + let channel_view = channel_cmds::ChannelView { + alpha_visible: (0..doc.channels.len()).map(|i| photocraft_compose::multichannel::is_overprint_spot(&doc, i)).collect(), + ..Default::default() + }; let doc = Arc::new(doc); // Photoshop's first snapshot: the document as opened, named after it, so the History // panel's top row can always bring it back (#2966). @@ -100,7 +105,7 @@ impl DocState { view_revision: 0, last_damage: None, coalesce: None, - channel_view: Default::default(), + channel_view, isolated_layers: Vec::new(), symmetry: None, fx_collapsed: Vec::new(), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 94adb15a0e..15ae84a291 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1084,6 +1084,22 @@ impl Session { self.start_open_inner(name, source, true, false, None, None, background) } + /// Place a file's bytes in the background, like `file.placeEmbedded` (`linked`: `file.placeLinked` + /// to that path) for bytes that have no path to read: decode, flatten and fit on a worker, then + /// insert the layer as one undo step when [`Session::poll_jobs`] applies it. `name` names the + /// layer and the file; `params` are the command's (`scale`, `center`, `fit`, `smartObject`). + /// The job's result is the command's: `{layer, frame, scale, bounds, smartObject}`. The active + /// document is locked meanwhile, so this is refused while another job runs on it. Runs inline + /// on wasm. + pub fn start_place_bytes(&mut self, name: &str, bytes: Vec, linked: Option, params: Value) -> Result { + let input = crate::file_cmds::PlaceInput::new(self, name, bytes, linked, ¶ms)?; + let (command, label) = (input.command(), input.job_label()); + if let Some(conflict) = self.job_conflict(command, true) { + return Err(EngineError::Other(conflict)); + } + self.start_job(command, json!({"name": name}), &label, true, move |ctx| crate::file_cmds::prepare_place(input, ctx), crate::file_cmds::apply_place) + } + /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. // Keep the worker and apply closures together with their execution options. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 18f8544d80..e74b784519 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod layer_style; pub mod link_watch; pub mod malloc_tuning; pub mod memory; +pub mod memory_gate; pub mod new_layer; mod param_check; mod pixels; @@ -174,6 +175,16 @@ pub use session::move_item; use session::restore_target; pub(crate) use session::{carry_visibility, fix_selection, visibility_changes}; +/// A type layer for [`Session::refresh_type_layers`]: in the open document, or in its first +/// History snapshot (the document as opened) when `first_snapshot`. The caller picks each against +/// that version of the layer, which may differ from the open one or be gone from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TypeLayerRef { + pub doc: photocraft_doc::DocId, + pub layer: LayerId, + pub first_snapshot: bool, +} + /// The pixels of a raster layer (typically one a command just created), as an error instead /// of a panic if the layer has none. pub(crate) fn pixels_mut(l: &mut photocraft_doc::Layer) -> Result<&mut photocraft_raster::Surface> { diff --git a/crates/engine/src/memory_gate.rs b/crates/engine/src/memory_gate.rs new file mode 100644 index 0000000000..67fc6816f3 --- /dev/null +++ b/crates/engine/src/memory_gate.rs @@ -0,0 +1,346 @@ +//! Refuses a big operation that cannot fit in memory, with an error, before it starts (#10024). +//! +//! On the web build a failed allocation aborts the whole module and the page freezes without a +//! message. [`photocraft_raster::headroom`] tells whether a given number of bytes can still be +//! allocated; an operation that is about to allocate a lot asks here first, with a rough upper +//! bound of what it will need. A refusal changes nothing (the document, its history and the +//! selection stay as they were) and says what the user can do. Off the web every check passes. +//! +//! The estimates are deliberately rough and err high: they count the big buffers an operation +//! holds at once, not every temporary. A layered PSD/PSB adds a canvas-sized surface per layer +//! to the flat-image estimate; one whose header or sections can't be read, or that isn't 8-bit +//! RGB or grayscale, has no safe bound and is refused by the web gate before decoding. + +use photocraft_doc::{Document, LayerContent, SmartSource}; +use photocraft_geom::TILE_SIZE; + +use crate::{EngineError, Result}; + +/// Peak bytes per source pixel while a smart object is rendered from its source: the decoded +/// source, its composite, the converted surface and the transformed result, measured on a large +/// embedded file. Lower it when the decoded originals stop being kept alongside (#10030). +pub const SMART_RENDER_BYTES_PER_PX: u64 = 41; + +/// Bytes per pixel of the compositor's working buffer (`RGBA`, `f32`) that flattening a placed +/// file fills. +const COMPOSITE_BYTES_PER_PX: u64 = 16; + +/// Pixels assumed per byte of a file whose size can't be read from its header (a rough bound: a +/// compressed image holds a few pixels per byte). +const FALLBACK_PX_PER_FILE_BYTE: u64 = 4; + +/// Bytes per canvas pixel each layer of a layered PSD/PSB keeps once decoded, on top of the flat +/// estimate: an 8-bit RGBA surface and its mask, assumed to cover the whole canvas. +const PSD_LAYER_BYTES_PER_PX: u64 = 5; + +/// `Ok` when `need` bytes can be allocated; otherwise an error naming `what` (for example +/// `place “x.png”`), the need, and what the user can do. The document is not touched. +pub fn ensure(what: &str, need: u64) -> Result<()> { + check(what, need, "Save your work, close other documents or use Edit › Purge › All, then try again.") +} + +/// [`ensure`] for saving and exporting: the user must be able to keep their work, so the advice +/// leads with freeing memory. The document stays open and unsaved. +pub fn ensure_for_save(what: &str, need: u64) -> Result<()> { + check(what, need, "Use Edit › Purge › All or close other documents to free memory, then save again. The document is still open and unchanged.") +} + +fn check(what: &str, need: u64, advice: &str) -> Result<()> { + photocraft_raster::headroom::ensure(need).map_err(|_| { + if need == u64::MAX { + // Freeing memory cannot satisfy an unbounded need, so the retry advice would mislead. + EngineError::Other(format!( + "Not enough memory to safely {what} in the browser: the file has a format whose memory use cannot be bounded in advance. Open it in the desktop app, or flatten it to a plain 8-bit RGB or grayscale image first." + )) + } else { + EngineError::Other(format!("Not enough memory to {what} (it needs about {} MB). {advice}", mb(need))) + } + }) +} + +/// `bytes` in whole megabytes, rounded up, with thousands separators. +fn mb(bytes: u64) -> String { + let n = bytes.div_ceil(1 << 20).to_string(); + let mut out = String::with_capacity(n.len() + n.len() / 3); + for (i, c) in n.chars().enumerate() { + if i > 0 && (n.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Pixel count of an image file read from its header, without decoding it. Formats without a +/// header reader here fall back to a size-based guess, so the answer is always an upper-bound +/// style estimate rather than an error. +pub(crate) fn source_pixels(bytes: &[u8]) -> u64 { + header_pixels(bytes).unwrap_or_else(|| (bytes.len() as u64).saturating_mul(FALLBACK_PX_PER_FILE_BYTE)) +} + +fn header_pixels(bytes: &[u8]) -> Option { + if let Ok((w, h)) = photocraft_codecs::probe_size(bytes) { + return Some(u64::from(w) * u64::from(h)); + } + let be32 = |at: usize| -> Option { Some(u64::from(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))) }; + let be16 = |at: usize| -> Option { Some(u64::from(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))) }; + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + // IHDR: width, height. + return Some(be32(16)?.saturating_mul(be32(20)?)); + } + if bytes.starts_with(b"8BPS") { + // PSD / PSB header: height then width. + return Some(be32(14)?.saturating_mul(be32(18)?)); + } + if bytes.starts_with(&[0xff, 0xd8]) { + // JPEG: the first start-of-frame marker holds height then width. + let mut at = 2usize; + while at.checked_add(4)? <= bytes.len() { + if *bytes.get(at)? != 0xff { + at += 1; + continue; + } + let marker = *bytes.get(at + 1)?; + match marker { + 0xff => at += 1, + 0xc0..=0xcf if !matches!(marker, 0xc4 | 0xc8 | 0xcc) => return Some(be16(at + 5)?.saturating_mul(be16(at + 7)?)), + 0xd0..=0xd9 | 0x01 | 0x00 => at += 2, + _ => at = at.checked_add(2)?.checked_add(usize::try_from(be16(at + 2)?).ok()?)?, + } + } + } + None +} + +/// The number of layers a PSD/PSB file decodes to, read from its sections without decoding: 0 +/// for a flat file. The color-mode data and image resources (resolution, ICC profile, thumbnail +/// and the like) hold no layer pixels and are only skipped. `None` when the header or a section +/// can't be read, or the file isn't 8-bit RGB or grayscale with at most 4 channels: those have +/// no safe bound. +fn psd_layer_count(bytes: &[u8]) -> Option { + let header = bytes.get(..26)?; + let version = u16::from_be_bytes([header[4], header[5]]); + let channels = u16::from_be_bytes([header[12], header[13]]); + let depth = u16::from_be_bytes([header[22], header[23]]); + let mode = u16::from_be_bytes([header[24], header[25]]); + if !matches!(version, 1 | 2) || !(1..=4).contains(&channels) || depth != 8 || !matches!(mode, 1 | 3) { + return None; + } + // A big-endian length field of `width` bytes at `at`. + let length = |at: usize, width: usize| -> Option { + let field = bytes.get(at..at.checked_add(width)?)?; + usize::try_from(field.iter().fold(0u64, |n, &b| (n << 8) | u64::from(b))).ok() + }; + let mut at = 26usize; + for _ in 0..2 { + // Color-mode data, then image resources. + at = at.checked_add(4)?.checked_add(length(at, 4)?)?; + } + let width = if version == 1 { 4 } else { 8 }; + let layers_and_masks = length(at, width)?; + let start = at.checked_add(width)?; + // The merged image's compression field follows the section. + if bytes.len().saturating_sub(start.checked_add(layers_and_masks)?) < 2 { + return None; + } + if layers_and_masks == 0 { + return Some(0); + } + // The layer info opens with its own length, then the layer count (negative when the first + // alpha channel holds the merged image's transparency). + let layer_info = length(start, width)?; + if layer_info == 0 { + // Some writers keep the layers in a `Layr` tagged block instead, which isn't counted here. + let section = bytes.get(start..start.checked_add(layers_and_masks)?)?; + return if section.windows(8).any(|w| w == b"8BIMLayr" || w == b"8B64Layr") { None } else { Some(0) }; + } + if layer_info.checked_add(width)? > layers_and_masks { + return None; + } + let count_at = start.checked_add(width)?; + let count = <[u8; 2]>::try_from(bytes.get(count_at..count_at.checked_add(2)?)?).ok()?; + Some(u64::from(i16::from_be_bytes(count).unsigned_abs())) +} + +/// Bytes per source pixel a file keeps beyond the flat estimate: a canvas-sized surface per layer +/// of a layered PSD/PSB, nothing for other files. `None` for a PSD/PSB with no safe bound, which +/// the web gate then refuses before decoding; native behavior is unchanged. +fn layer_bytes_per_px(bytes: &[u8]) -> Option { + if !bytes.starts_with(b"8BPS") { + return Some(0); + } + Some(psd_layer_count(bytes)?.saturating_mul(PSD_LAYER_BYTES_PER_PX)) +} + +/// Place: the file's bytes, plus the decoded file, its composite and the placed pixels, which +/// are alive together (`bytes_per_px` is the document's pixel size, at least 4), plus the layers +/// of a layered PSD/PSB. +pub(crate) fn place_need(bytes: &[u8], bytes_per_px: usize) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + let px = source_pixels(bytes); + let per_px = (bytes_per_px.max(4) as u64).saturating_mul(3).saturating_add(COMPOSITE_BYTES_PER_PX).saturating_add(layers); + (bytes.len() as u64).saturating_add(px.saturating_mul(per_px)) +} + +/// A smart object's re-render from `bytes`: its source pixels at the measured peak, plus the +/// layers of a layered PSD/PSB. +pub(crate) fn smart_render_need(bytes: &[u8]) -> u64 { + let Some(layers) = layer_bytes_per_px(bytes) else { return u64::MAX }; + source_pixels(bytes).saturating_mul(SMART_RENDER_BYTES_PER_PX.saturating_add(layers)) +} + +/// Image Size to `nw` x `nh`: the resampled pixel surfaces and masks (each layer's tiles scaled +/// by the change of area, capped at a full canvas) in the document's pixel size, plus every +/// distinct embedded smart object re-rendered from its source at once. +pub(crate) fn image_size_need(doc: &Document, nw: u32, nh: u32) -> u64 { + let bpp = doc.pixel_format().bytes_per_pixel().max(1) as u64; + let old_px = (u64::from(doc.size.width) * u64::from(doc.size.height)).max(1); + let new_px = u64::from(nw) * u64::from(nh); + let canvas_bytes = new_px.saturating_mul(bpp); + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * bpp; + let resized = |tiles: usize| -> u64 { + let old = (tiles as u64).saturating_mul(tile_bytes); + let scaled = (old as u128 * u128::from(new_px) / u128::from(old_px)).min(u128::from(u64::MAX)) as u64; + scaled.max(tile_bytes.min(canvas_bytes)).min(canvas_bytes) + }; + let mut need = 0u64; + let mut seen = std::collections::HashSet::new(); + for (_, _, layer) in doc.walk() { + if let Some(s) = layer.surface() { + need = need.saturating_add(resized(s.tile_count())); + } + if let Some(m) = &layer.mask { + need = need.saturating_add(resized(m.surface.tile_count())); + } + if let LayerContent::Smart(so) = &layer.content { + let embedded = match &so.source { + SmartSource::Embedded { bytes, .. } => Some(bytes.as_slice()), + SmartSource::Linked { .. } => None, + }; + if let Some(bytes) = embedded + && seen.insert(so.contents_id) + { + need = need.saturating_add(smart_render_need(bytes)); + } + } + } + need +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn megabytes_are_rounded_up_and_grouped() { + assert_eq!(mb(0), "0"); + assert_eq!(mb(1), "1"); + assert_eq!(mb(1240 << 20), "1,240"); + assert_eq!(mb(1_000 << 20), "1,000"); + assert_eq!(mb(12_345_678 << 20), "12,345,678"); + } + + #[test] + fn headers_give_pixel_counts() { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend_from_slice(&300u32.to_be_bytes()); + png.extend_from_slice(&200u32.to_be_bytes()); + assert_eq!(source_pixels(&png), 60_000); + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); + psd.extend_from_slice(&50u32.to_be_bytes()); + psd.extend_from_slice(&40u32.to_be_bytes()); + assert_eq!(source_pixels(&psd), 2_000); + // SOI, an APP0 segment of length 4, then SOF0: precision, height 20, width 30. + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + assert_eq!(source_pixels(&jpeg), 600); + } + + #[test] + fn malformed_psd_sections_are_refused_without_panicking() { + let mut flat = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + flat.extend_from_slice(&[0; 14]); + assert_eq!(psd_layer_count(&flat), Some(0)); + for end in 4..flat.len() { + assert_eq!(place_need(&flat[..end], 4), u64::MAX); + assert_eq!(smart_render_need(&flat[..end]), u64::MAX); + } + flat[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(place_need(&flat, 4), u64::MAX); + } + + /// A 2 x 2 RGB PSD with a resolution resource (as every Photoshop file has) and, when + /// `layers` is set, a layer info section holding only that layer count. + fn psd_with_resources(layers: Option) -> Vec { + let mut psd = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + psd.extend_from_slice(&[0; 4]); + // ResolutionInfo (0x03ED): signature, id, empty name padded to even, 16 bytes of data. + let mut resource = b"8BIM\x03\xed\0\0".to_vec(); + resource.extend_from_slice(&16u32.to_be_bytes()); + resource.extend_from_slice(&[0, 0x48, 0, 0, 0, 1, 0, 1, 0, 0x48, 0, 0, 0, 1, 0, 1]); + psd.extend_from_slice(&(resource.len() as u32).to_be_bytes()); + psd.extend_from_slice(&resource); + match layers { + None => psd.extend_from_slice(&[0; 4]), + Some(n) => { + psd.extend_from_slice(&6u32.to_be_bytes()); + psd.extend_from_slice(&2u32.to_be_bytes()); + psd.extend_from_slice(&n.to_be_bytes()); + } + } + psd.extend_from_slice(&[0; 2]); + psd.extend_from_slice(&[128; 12]); + psd + } + + #[test] + fn image_resources_do_not_make_a_psd_unbounded() { + let psd = psd_with_resources(None); + assert_eq!(psd_layer_count(&psd), Some(0)); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * SMART_RENDER_BYTES_PER_PX); + } + + #[test] + fn a_layered_psd_counts_a_canvas_sized_surface_per_layer() { + // A negative count (merged transparency in the first alpha channel) is still 3 layers. + for n in [3i16, -3] { + let psd = psd_with_resources(Some(n)); + assert_eq!(psd_layer_count(&psd), Some(3), "{n}"); + assert_eq!(place_need(&psd, 4), psd.len() as u64 + 4 * (4 * 3 + COMPOSITE_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + assert_eq!(smart_render_need(&psd), 4 * (SMART_RENDER_BYTES_PER_PX + 3 * PSD_LAYER_BYTES_PER_PX)); + } + // A layer info length that runs past its section can't be trusted. + let mut psd = psd_with_resources(Some(3)); + let info_at = psd.len() - 14 - 6; + psd[info_at..info_at + 4].copy_from_slice(&100u32.to_be_bytes()); + assert_eq!(place_need(&psd, 4), u64::MAX); + // Layers kept in a tagged block instead of the layer info aren't counted, so refused. + let mut tagged = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(&20u32.to_be_bytes()); + tagged.extend_from_slice(&[0; 8]); + tagged.extend_from_slice(b"8BIMLayr"); + tagged.extend_from_slice(&[0; 4 + 2 + 12]); + assert_eq!(place_need(&tagged, 4), u64::MAX); + // Cut anywhere before the merged image's compression field, it's refused. + let psd = psd_with_resources(Some(3)); + for end in 4..psd.len() - 12 { + assert_eq!(place_need(&psd[..end], 4), u64::MAX, "{end}"); + } + } + + #[test] + fn unreadable_headers_fall_back_to_a_size_guess_without_panicking() { + assert_eq!(source_pixels(b""), 0); + assert_eq!(source_pixels(&[0u8; 100]), 400); + for cut in 0..40 { + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x10\0\0\0\x10".to_vec(); + png.truncate(cut); + let _ = source_pixels(&png); + let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 30, 1]; + let _ = source_pixels(&jpeg[..cut.min(jpeg.len())]); + } + // A segment length that runs off the end of the file ends the scan. + let _ = source_pixels(&[0xff, 0xd8, 0xff, 0xe0, 0xff, 0xff]); + } +} diff --git a/crates/engine/src/session/layers.rs b/crates/engine/src/session/layers.rs index 2e6009d16f..4511b7fbf1 100644 --- a/crates/engine/src/session/layers.rs +++ b/crates/engine/src/session/layers.rs @@ -1,5 +1,6 @@ use super::history::fix_selection; use super::{Arc, EngineError, LayerId, Result, Session, channel_cmds, layer_multi_cmds, type_cmds}; +use crate::TypeLayerRef; impl Session { /// Replace the active document's active layer without creating a history step. @@ -52,41 +53,70 @@ impl Session { /// unchanged, so this is no history step and leaves a clean document clean. Unknown documents /// and layers are skipped. A document a background job is computing from must not move under /// it: its layers are returned, to try again later. - pub fn refresh_type_layers(&mut self, layers: &[(photocraft_doc::DocId, LayerId)]) -> Vec<(photocraft_doc::DocId, LayerId)> { + pub fn refresh_type_layers(&mut self, layers: &[TypeLayerRef]) -> Vec { let mut busy = Vec::new(); let mut docs: Vec = Vec::new(); - for (d, _) in layers { - if !docs.contains(d) { - docs.push(*d); + for l in layers { + if !docs.contains(&l.doc) { + docs.push(l.doc); } } for doc_id in docs { - let mine = layers.iter().filter(|(d, _)| *d == doc_id); if self.job_on(doc_id).is_some() { - busy.extend(mine); + busy.extend(layers.iter().filter(|l| l.doc == doc_id)); continue; } + let ids = |first: bool| -> Vec { layers.iter().filter(|l| l.doc == doc_id && l.first_snapshot == first).map(|l| l.layer).collect() }; let Some(st) = self.docs.iter_mut().find(|st| st.doc.id == doc_id) else { continue }; - let snapshot = st.doc.clone(); - let mut doc = (*snapshot).clone(); - let mut changed = false; - for (_, id) in mine { - if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = doc.layer_mut(*id) { - type_cmds::refresh(&snapshot, t); - changed = true; + // The first snapshot is the document as opened (#2966, #6087): it was taken before the + // font arrived, so its type layers get the same refresh, or returning to it brings the + // fallback-font pixels back. While it still is the open document, it shares the + // refreshed one; once edited, it is refreshed on its own, layers the edit removed too. + let first_is_doc = st.snapshots.iter().any(|s| s.id == 0 && Arc::ptr_eq(&s.doc, &st.doc)); + if !first_is_doc + && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) + && let Some(doc) = refresh_text_layers(&first.doc, &ids(true)) + { + first.doc = Arc::new(doc); + } + // While shared, a layer picked in the snapshot is a layer of the open document too + // (a document busy when its font arrived can be back at the snapshot by the retry). + let mut open = ids(false); + if first_is_doc { + for id in ids(true) { + if !open.contains(&id) { + open.push(id); + } } } - if changed { - let clean = st.saved_revision == st.revision; - st.doc = Arc::new(doc); - st.revision += 1; - if clean { - st.saved_revision = st.revision; - } - // Only the refreshed layers' caches changed: their changed tiles. - st.last_damage = layer_multi_cmds::step_damage(&snapshot, &st.doc); + let Some(doc) = refresh_text_layers(&st.doc, &open) else { continue }; + let doc = Arc::new(doc); + if first_is_doc && let Some(first) = st.snapshots.iter_mut().find(|s| s.id == 0) { + first.doc = doc.clone(); } + let clean = st.saved_revision == st.revision; + let before = std::mem::replace(&mut st.doc, doc); + st.revision += 1; + if clean { + st.saved_revision = st.revision; + } + // Only the refreshed layers' caches changed: their changed tiles. + st.last_damage = layer_multi_cmds::step_damage(&before, &st.doc); } busy } } + +/// `doc` with its type layers among `ids` re-rendered (`Session::refresh_type_layers`), or `None` +/// when none of them is a type layer of `doc`. +fn refresh_text_layers(doc: &Arc, ids: &[LayerId]) -> Option { + let mut out = (**doc).clone(); + let mut changed = false; + for id in ids { + if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = out.layer_mut(*id) { + type_cmds::refresh(doc, t); + changed = true; + } + } + changed.then_some(out) +} diff --git a/crates/engine/tests/it/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs index 16d8f3d459..1016aac628 100644 --- a/crates/engine/tests/it/svg_smart_object.rs +++ b/crates/engine/tests/it/svg_smart_object.rs @@ -62,3 +62,70 @@ fn re_placing_at_another_scale_re_renders_the_vector_source() { assert!(alpha(&surf, 268, 60) > 0.99 && alpha(&surf, 271, 60) < 0.01); assert!(red(&surf, 150, 81) < 0.02 && red(&surf, 150, 75) > 0.98 && red(&surf, 150, 87) > 0.98); } + +/// A `.pcraft` opens from its cached pixels and parses an embedded SVG source only on a later +/// edit. A source whose DTD entity hides 20 000 nested groups from the depth check used to open +/// clean and then overflow the stack on Image Size or a PSD save; both are an error or a fallback +/// now. Run on a 2 MB stack, as the app's import and command threads are. +#[test] +fn a_pcraft_smart_object_with_a_hostile_svg_does_not_abort_a_later_edit() { + let deep = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let hostile = format!("]>&a;"); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "white"})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut s, "logo.svg", SVG.as_bytes().to_vec(), None, &json!({"scale": 100})).unwrap(); + let mut doc = (*s.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layers.last_mut().unwrap().content else { panic!("smart object") }; + sm.source = photocraft_doc::SmartSource::Embedded { file_name: "logo.svg".into(), bytes: std::sync::Arc::new(hostile.into_bytes()) }; + let carrier = photocraft_format::save_to_bytes(&doc, &photocraft_format::SaveOptions::default()).unwrap(); + + photocraft_engine::file_cmds::open_bytes_as(&mut s, "carrier.pcraft", &carrier, None, None).unwrap(); + let _ = s.execute("image.imageSize", json!({"width": 50, "height": 50})); + let dir = tempfile::tempdir().unwrap(); + let _ = s.execute("file.saveACopy", json!({"path": dir.path().join("copy.psd").to_string_lossy()})); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Placed through a background job, an SVG is rendered at its placement scale on the worker and +/// lands exactly as the inline place does: still sharp, not a resampled raster. +#[test] +fn an_svg_placed_as_a_job_is_as_sharp_as_the_inline_place() { + use photocraft_engine::jobs::Started; + let dir = std::env::temp_dir().join(format!("photocraft-svg-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("logo.svg"); + std::fs::write(&path, SVG).unwrap(); + let params = json!({"path": path.to_string_lossy(), "scale": 1000}); + let new_doc = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap(); + s + }; + let mut inline = new_doc(); + let want = inline.execute("file.placeEmbedded", params.clone()).unwrap(); + + let mut s = new_doc(); + let Started::Job(id) = s.start("file.placeEmbedded", params).unwrap() else { panic!("expected a background job") }; + let mut got = s.wait_job(id).unwrap(); + // Layer ids come from a counter shared by every session: the rest of the result must match. + let mut want = want; + assert!(got.as_object_mut().and_then(|o| o.remove("layer")).is_some() && want.as_object_mut().and_then(|o| o.remove("layer")).is_some()); + assert_eq!(got, want); + assert_eq!(got["bounds"], json!([100.0, 150.0, 300.0, 250.0]), "{got}"); + let doc = s.active().unwrap().doc.clone(); + let layer = doc.layers.last().unwrap(); + assert!(matches!(layer.content, LayerContent::Smart(_))); + let surf = layer.surface().expect("rendered"); + assert!(alpha(surf, 200, 148) < 0.01 && alpha(surf, 200, 151) > 0.99, "a pixel-exact top edge"); + assert!(red(surf, 200, 192) < 0.02, "the thin line is a crisp band: {}", red(surf, 200, 192)); + assert!(red(surf, 200, 187) > 0.98 && red(surf, 200, 198) > 0.98); + let region = Rect::new(90, 140, 310, 260); + let inline_doc = inline.active().unwrap().doc.clone(); + assert_eq!(surf.read_region(region), inline_doc.layers.last().unwrap().surface().unwrap().read_region(region), "same pixels as the inline place"); +} diff --git a/crates/engine/tests/lut_row_cache_purge.rs b/crates/engine/tests/lut_row_cache_purge.rs new file mode 100644 index 0000000000..18d78c0c9f --- /dev/null +++ b/crates/engine/tests/lut_row_cache_purge.rs @@ -0,0 +1,30 @@ +//! One integration-test process keeps its global adjustment-table cache isolated from parallel +//! unit tests. +use photocraft_compose::lut_rows::{self, ROW_LEN}; +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn purge_all_is_enabled_by_cached_adjustment_tables_and_releases_them() { + let mut session = Session::new(); + assert!(!session.is_enabled("edit.purge.all")); + let rows = lut_rows::cached(7, || vec![[0.5; ROW_LEN]; 3]); + let accounted = lut_rows::cache_bytes(); + assert_eq!(accounted, 3 * ROW_LEN * 4); + assert!(session.is_enabled("edit.purge.all"), "only the adjustment table cache enables purge"); + + let result = session.execute("edit.purge.all", json!({})).unwrap(); + assert_eq!(result["purged"], json!(["adjustment table cache"])); + assert_eq!(result["bytes"].as_u64(), Some(accounted as u64)); + assert_eq!(lut_rows::cache_bytes(), 0); + assert!(!session.is_enabled("edit.purge.all")); + + // The next render builds the tables again. + let mut built = false; + let again = lut_rows::cached(7, || { + built = true; + vec![[0.5; ROW_LEN]; 3] + }); + assert!(built); + assert_eq!(rows, again); +} diff --git a/crates/engine/tests/memory_gate.rs b/crates/engine/tests/memory_gate.rs new file mode 100644 index 0000000000..a740012c76 --- /dev/null +++ b/crates/engine/tests/memory_gate.rs @@ -0,0 +1,261 @@ +//! On the web build a failed allocation aborts the module and freezes the page. The big +//! operations therefore ask the allocator first (`photocraft_raster::headroom`) and refuse with +//! an error when their estimated need won't fit (#10024). The simulation switch is process-wide, +//! so every test here holds `LOCK` and resets it when it ends. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_engine::Session; +use photocraft_raster::headroom; +use serde_json::json; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Holds the lock and the simulated room; resets the simulation when dropped. +struct Room(#[allow(dead_code)] MutexGuard<'static, ()>); + +impl Room { + fn new(bytes: Option) -> Room { + let guard = Room(LOCK.lock().unwrap_or_else(PoisonError::into_inner)); + headroom::simulate_available(bytes); + guard + } + fn set(&self, bytes: Option) { + headroom::simulate_available(bytes); + } +} + +impl Drop for Room { + fn drop(&mut self) { + headroom::simulate_available(None); + } +} + +const MIB: u64 = 1 << 20; + +/// Enough for the reserve and a small operation, far too little for a big one. +const SMALL: u64 = headroom::RESERVE + 4 * MIB; + +fn png(dir: &std::path::Path, name: &str, w: u32, h: u32, color: &str) -> String { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": color})).unwrap(); + let st = s.active().unwrap(); + let bytes = photocraft_io::export(&st.doc, "png", &Default::default()).unwrap().bytes; + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +/// What an operation must not change when the gate refuses it. +fn state(s: &Session) -> (u64, usize, usize, (u32, u32)) { + let st = s.active().unwrap(); + (st.revision, st.history.past_len(), st.doc.layers.len(), (st.doc.size.width, st.doc.size.height)) +} + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + s +} + +#[test] +fn place_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + // 2400 x 1800 px: about 4 MP, about 190 MB by the estimate. + let path = png(dir.path(), "big \u{e9}.png", 2400, 1800, "#ff0000"); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + let err = s.execute(cmd, json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to place \u{201c}big \u{e9}.png\u{201d}"), "{err}"); + assert!(err.contains("MB"), "names the need: {err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "says what to do: {err}"); + assert_eq!(state(&s), before, "{cmd}"); + } + room.set(None); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); +} + +#[test] +fn image_size_that_does_not_fit_fails_and_leaves_the_document_alone() { + let room = Room::new(None); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(SMALL)); + let err = s.execute("image.imageSize", json!({"width": 12000, "height": 9000})).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to resize the image to 12000 x 9000 px"), "{err}"); + assert_eq!(state(&s), before); + // Only changing the resolution allocates nothing, so it still goes through. + s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap(); + room.set(None); + s.execute("image.imageSize", json!({"width": 800, "height": 600})).unwrap(); + assert_eq!(state(&s).3, (800, 600)); +} + +/// A smart-object re-render that doesn't fit is refused by name (its own error); the commands +/// that re-render after an edit keep the layer's last appearance instead of failing. +#[test] +fn smart_object_render_that_does_not_fit_is_refused_by_name() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "src.png", 1500, 1500, "#12ab34"); + let bytes = std::fs::read(&path).unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + room.set(Some(SMALL)); + let err = photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).unwrap_err().to_string(); + assert!(err.contains("Not enough memory to render the smart object \u{201c}src.png\u{201d}"), "{err}"); + room.set(None); + assert!(photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).is_ok()); +} + +#[test] +fn layered_psd_is_refused_before_decode_without_changing_the_document() { + use photocraft_psd::{Compression, LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(None); + const SIDE: u32 = 256; + const LAYERS: usize = 24; + let pixels = u64::from(SIDE) * u64::from(SIDE); + let mut builder = PsdBuilder::new(SIDE, SIDE).compression(Compression::Rle); + for layer in 0..LAYERS { + // Nonuniform, opaque pixels populate each full-canvas surface while remaining compressible. + let rgba: Vec = (0..SIDE * SIDE).flat_map(|i| [((i / SIDE + layer as u32) % 256) as u8, 80, 160, 255]).collect(); + builder.push_layer(LayerSpec::new(format!("layer {layer}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba))); + } + let bytes = builder.to_bytes().unwrap(); + let budget = pixels * photocraft_engine::memory_gate::SMART_RENDER_BYTES_PER_PX + bytes.len() as u64; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layers.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + room.set(Some(headroom::RESERVE)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); + + // The layers make the need bigger but bounded, so the error names it. + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("MB"), "{err}"); + + // This budget passed the old canvas-only estimate even though the layers retain more. + room.set(Some(headroom::RESERVE + budget)); + for cmd in ["file.placeEmbedded", "file.placeLinked"] { + assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory")); + assert_eq!(state(&s), before); + } + assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err()); +} + +#[test] +fn small_flat_psd_still_places_and_renders() { + let room = Room::new(Some(SMALL)); + // Two-by-two RGB PSD: empty color, resource and layer sections, raw planar pixels. + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 12]); + bytes.extend_from_slice(&[0, 0]); + bytes.extend_from_slice(&[128; 12]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("flat.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + assert!(photocraft_engine::smart_cmds::source_image("flat.psd", &bytes, fmt).is_ok()); + drop(room); +} + +/// Every Photoshop file has image resources (resolution, ICC profile, thumbnail); they hold no +/// pixels, so a PSD that has them, flat or with a few layers, still places and renders when the +/// memory is there. +#[test] +fn a_psd_with_image_resources_places_and_renders_when_it_fits() { + use photocraft_psd::{LayerSpec, PixelData, PsdBuilder}; + + let room = Room::new(Some(headroom::RESERVE + 256 * MIB)); + const SIDE: u32 = 64; + let rgba = |r: u8| -> Vec { (0..SIDE * SIDE).flat_map(|_| [r, 80, 160, 255]).collect() }; + let mut flat = PsdBuilder::new(SIDE, SIDE).resolution(72.0); + flat.composite(PixelData::Rgba8(rgba(10))); + let mut layered = PsdBuilder::new(SIDE, SIDE).resolution(150.0); + for (i, r) in [20u8, 30].into_iter().enumerate() { + layered.push_layer(LayerSpec::new(format!("layer {i}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba(r)))); + } + let dir = tempfile::tempdir().unwrap(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut s = new_doc(); + for (name, builder) in [("resources.psd", flat), ("resources-layers.psd", layered)] { + let bytes = builder.to_bytes().unwrap(); + // No color-mode data for RGB, then a non-empty image resources section. + assert_ne!(&bytes[30..34], &[0u8; 4][..], "{name} has image resources"); + assert!(photocraft_engine::smart_cmds::source_image(name, &bytes, fmt).is_ok(), "{name}"); + let path = dir.path().join(name); + std::fs::write(&path, &bytes).unwrap(); + let before = state(&s); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + assert_eq!(state(&s).2, before.2 + 1, "{name}"); + } + drop(room); +} + +/// A PSD with no safe bound (here 16-bit) is refused whatever the free memory, so the error +/// doesn't suggest freeing some. +#[test] +fn a_psd_without_a_bound_says_to_flatten_it() { + let room = Room::new(Some(headroom::RESERVE + 1024 * MIB)); + let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x10\0\x03".to_vec(); + bytes.extend_from_slice(&[0; 14]); + bytes.extend_from_slice(&[128; 24]); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deep.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = new_doc(); + let before = state(&s); + let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string(); + assert!(!err.contains("Purge"), "freeing memory cannot help an unbounded source: {err}"); + assert!(err.contains("flatten"), "{err}"); + assert_eq!(state(&s), before); + drop(room); +} + +#[test] +fn a_smart_object_edit_under_low_memory_keeps_its_appearance() { + let room = Room::new(None); + let dir = tempfile::tempdir().unwrap(); + let path = png(dir.path(), "edit.png", 1500, 1500, "#a1b2c3"); + let mut s = new_doc(); + s.execute("file.placeEmbedded", json!({"path": path})).unwrap(); + let before = state(&s); + room.set(Some(SMALL)); + s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap(); + assert_eq!(state(&s).2, before.2, "the layer is still there"); +} + +#[test] +fn everything_passes_without_a_simulation() { + let _room = Room::new(None); + assert!(photocraft_engine::memory_gate::ensure("do anything", 64 << 30).is_ok()); + assert!(photocraft_engine::memory_gate::ensure_for_save("save", 64 << 30).is_ok()); +} + +#[test] +fn the_save_wording_leads_with_freeing_memory_and_keeps_the_document() { + let room = Room::new(Some(SMALL)); + let err = photocraft_engine::memory_gate::ensure_for_save("save \u{201c}a.psd\u{201d}", 900 * MIB).unwrap_err().to_string(); + assert!(err.starts_with("Not enough memory to save \u{201c}a.psd\u{201d} (it needs about 900 MB)."), "{err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}"); + assert!(err.contains("still open"), "{err}"); + drop(room); +} diff --git a/crates/gpu/examples/compositor_build_probe.rs b/crates/gpu/examples/compositor_build_probe.rs new file mode 100644 index 0000000000..932057f1fc --- /dev/null +++ b/crates/gpu/examples/compositor_build_probe.rs @@ -0,0 +1,189 @@ +//! Measure compositor pipeline construction and, optionally, device work during a background build. +//! +//! `cargo run --release -p photocraft-gpu --example compositor_build_probe -- [dx12|vulkan|metal|gl] [fxc|dxc] [rounds=3] [threads=N] [--background] [--json out.json]` +//! +//! The explicit thread count is passed to the dedicated Rayon build pool. + +use photocraft_gpu::Compositor; +use std::fs; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +struct Sample { + build_ms: f64, + ok: bool, + error: Option, +} + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let backend = args.first().map(String::as_str).unwrap_or("dx12"); + let compiler = args.get(1).map(String::as_str).unwrap_or("fxc"); + let rounds = args.iter().find_map(|a| a.strip_prefix("rounds=").and_then(|s| s.parse::().ok())).unwrap_or(3).max(1); + let default_threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)); + let threads = args.iter().find_map(|a| a.strip_prefix("threads=").and_then(|s| s.parse::().ok())).unwrap_or(default_threads).max(1); + let background = args.iter().any(|a| a == "--background"); + let json_path = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1)); + let backends = match backend { + "vulkan" => wgpu::Backends::VULKAN, + "metal" => wgpu::Backends::METAL, + "gl" => wgpu::Backends::GL, + _ => wgpu::Backends::DX12, + }; + let shader_compiler = match compiler { + "dxc" => wgpu::Dx12Compiler::default_dynamic_dxc(), + _ => wgpu::Dx12Compiler::Fxc, + }; + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = backends; + descriptor.flags = wgpu::InstanceFlags::empty(); + descriptor.backend_options.dx12.shader_compiler = shader_compiler; + let instance = wgpu::Instance::new(descriptor); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { force_fallback_adapter: true, ..Default::default() }))?; + let info = adapter.get_info(); + println!("adapter={} backend={:?} driver={} {} threads={threads}", info.name, info.backend, info.driver, info.driver_info); + + let mut samples = Vec::with_capacity(rounds); + let mut stall = Vec::new(); + let mut cpu_idle_ms = None; + let mut cpu_during_ms = None; + for round in 0..rounds { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))?; + let format = Compositor::preferred_acc_format(&adapter); + if background { + if round == 0 { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_idle_ms = Some(start.elapsed().as_secs_f64() * 1000.0); + } + let (build_tx, build_rx) = std::sync::mpsc::channel(); + let start = Instant::now(); + std::thread::scope(|scope| { + scope.spawn(|| { + let build_start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let elapsed = build_start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + let _ = build_tx.send((error, elapsed)); + }); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("compositor probe UI target"), + size: wgpu::Extent3d { width: 256, height: 256, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let mut last_cpu = start; + loop { + match build_rx.try_recv() { + Ok((error, build_ms)) => { + samples.push(Sample { build_ms, ok: error.is_none(), error }); + break; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + samples.push(Sample { build_ms: start.elapsed().as_secs_f64() * 1000.0, ok: false, error: Some("build thread terminated".into()) }); + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => (), + } + let tick = Instant::now(); + let mut encoder = device.create_command_encoder(&Default::default()); + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("probe frame"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store }, + })], + ..Default::default() + }); + drop(_pass); + queue.submit([encoder.finish()]); + let _ = device.poll(wgpu::PollType::Poll); + stall.push(tick.elapsed().as_secs_f64() * 1000.0); + if round == 0 && cpu_during_ms.is_none() && last_cpu.elapsed() >= Duration::from_millis(100) { + let doc = photocraft_doc::Document::with_background( + "compositor build probe", + photocraft_geom::Size::new(1920, 1080), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::WHITE, + ); + let cpu_start = Instant::now(); + let _ = photocraft_compose::render(&doc, doc.bounds()); + cpu_during_ms = Some(cpu_start.elapsed().as_secs_f64() * 1000.0); + last_cpu = Instant::now(); + } + std::thread::sleep(Duration::from_millis(16)); + } + }); + } else { + let start = Instant::now(); + let result = Compositor::try_new_with_format_threads(&device, format, threads); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let error = result.err().map(|e| e.to_string()); + samples.push(Sample { build_ms, ok: error.is_none(), error }); + } + let s = samples.last().ok_or("missing compositor build sample")?; + println!("round={round} build_ms={:.3} ok={} error={}", s.build_ms, s.ok, s.error.as_deref().unwrap_or("")); + } + + stall.sort_by(f64::total_cmp); + let p95 = stall.get((stall.len() * 95 / 100).min(stall.len().saturating_sub(1))).copied().unwrap_or(0.0); + let max = stall.last().copied().unwrap_or(0.0); + println!("ui_stall_p95_ms={p95:.3} ui_stall_max_ms={max:.3} cpu_idle_ms={cpu_idle_ms:?} cpu_during_ms={cpu_during_ms:?}"); + if let Some(path) = json_path { + let rounds_json = samples + .iter() + .map(|s| { + format!("{{\"build_ms\":{:.3},\"ok\":{},\"error\":{}}}", s.build_ms, s.ok, s.error.as_deref().map(json_string).unwrap_or_else(|| "null".into())) + }) + .collect::>() + .join(","); + let json = format!( + "{{\"backend\":{},\"compiler\":{},\"adapter\":{},\"threads\":{},\"rounds\":[{}],\"stall\":{{\"p95_ms\":{p95:.3},\"max_ms\":{max:.3}}},\"cpu_composite_ms\":{{\"idle\":{},\"during_build\":{}}}}}", + json_string(backend), + json_string(compiler), + json_string(&info.name), + threads, + rounds_json, + optional_number(cpu_idle_ms), + optional_number(cpu_during_ms) + ); + fs::write(path, json)?; + } + Ok(()) +} + +fn json_string(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn optional_number(n: Option) -> String { + n.map(|v| format!("{v:.3}")).unwrap_or_else(|| "null".into()) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ddeaa7a52d..e4f0890884 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -141,6 +141,7 @@ mod paging; #[cfg(not(target_arch = "wasm32"))] pub mod readback; mod setup; +pub use setup::{BuildFailure, BuildFailureKind}; mod textures; #[cfg(test)] @@ -167,5 +168,20 @@ use textures::TexKind; use textures::{CachedPattern, FxEntry, FxKey, ResKey, Resident, Tex}; pub use textures::{ChunkOut, Stats}; +/// Stable fingerprint of the compositor WGSL source, for invalidating cached build failures. +/// Uses 64-bit FNV-1a over the exact embedded source bytes. +pub fn shader_fingerprint() -> u64 { + fnv1a(SHADER.as_bytes()) +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x00000100000001b3); + } + hash +} + #[cfg(test)] mod tests; diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index a38cdde867..202f0216e1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -82,6 +82,68 @@ fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> { type Program = (i32, [[f32; 4]; 4], Option>); +/// What the LUT rows of `adj` at `depth` are built from, hashed bit for bit (so `-0.0` and `0.0` +/// stay apart): every field the row builder reads and nothing else, so settings outside the +/// tables (Hue/Saturation's master sliders) don't add entries. The settings are destructured in +/// full (Hue/Saturation's builder takes only `ranges`), so a new field fails to compile here until +/// it is placed. `None`: rows aren't cached. +pub(super) fn row_key(adj: &Adjustment, depth: photocraft_color::SampleType) -> Option { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let level = |h: &mut std::collections::hash_map::DefaultHasher, c: &LevelsChannel| { + let LevelsChannel { in_black, in_white, gamma, out_black, out_white } = c; + [in_black, in_white, gamma, out_black, out_white].map(|v| v.to_bits()).hash(h); + }; + let curve = |h: &mut std::collections::hash_map::DefaultHasher, c: &[CurvePoint]| { + c.len().hash(h); + for CurvePoint { input, output } in c { + (input.to_bits(), output.to_bits()).hash(h); + } + }; + match adj { + // adjust::tone_luts_depth, which rounds to the depth's quantum. + Adjustment::Levels { master, per_channel, space, black } => { + (0u8, depth, *space as u8).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + level(&mut h, c); + } + } + Adjustment::Curves { master, per_channel, space, black, pencil } => { + (1u8, depth, *space as u8, *pencil).hash(&mut h); + for c in [master, &per_channel[0], &per_channel[1], &per_channel[2], black] { + curve(&mut h, c); + } + } + // adjust::hue_range_tables reads only the ranges, at any depth. + Adjustment::HueSaturation { ranges, .. } => { + 2u8.hash(&mut h); + for HueRange { hue, saturation, lightness, bounds } in ranges { + [hue, saturation, lightness].map(|v| v.to_bits()).hash(&mut h); + bounds.map(f32::to_bits).hash(&mut h); + } + } + // adjust::BalanceCurve::rgb, at any depth. + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + (3u8, *preserve_luminosity).hash(&mut h); + for tone in [shadows, midtones, highlights] { + tone.map(f32::to_bits).hash(&mut h); + } + } + _ => return None, + } + Some(h.finish()) +} + +/// LUT rows for `adj` at `depth`, built by `build` once per [`row_key`] and reused by later plans +/// (`compose::lut_rows`): the Levels tables alone took 1–2 ms per layer on every plan (#11498). +fn cached_rows(adj: &Adjustment, depth: photocraft_color::SampleType, build: impl FnOnce() -> Vec<[f32; 4096]>) -> Vec<[f32; 4096]> { + match row_key(adj, depth) { + Some(key) => photocraft_compose::lut_rows::cached(key, build), + None => build(), + } +} + /// A CPU LUT (4096 entries) as one texture row. fn to_row(t: &[f32]) -> [f32; 4096] { let mut row = [0.0f32; 4096]; @@ -173,7 +235,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } // p0.w: continue the curve past 0..1 (32-bit RGB Curves, #7157). p[0][3] = if adjust::float_tone_extrapolates(adj, Some(depth), mode) { 1.0 } else { 0.0 }; - (7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect())) + (7, p, Some(cached_rows(adj, depth, || adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))) } Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => { let sat = adjust::colorize_saturation(saturation / 100.0, lightness / 100.0, *colorize, Some(depth)); @@ -181,7 +243,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf if !*colorize && ranges.iter().any(|r| !r.is_neutral()) { // Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU. p[1][0] = 1.0; - (8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect())) + (8, p, Some(cached_rows(adj, depth, || adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))) } else { (8, p, None) } @@ -234,8 +296,11 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { // Per-channel curves (compose::adjust::BalanceCurve), through the Levels kernel. - let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); - (7, p, Some(lut_rows(|ch, v| curves[ch].eval(v), 3))) + let rows = || { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + lut_rows(|ch, v| curves[ch].eval(v), 3) + }; + (7, p, Some(cached_rows(adj, depth, rows))) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). diff --git a/crates/gpu/src/plan/tests.rs b/crates/gpu/src/plan/tests.rs index 83f286bf2b..00153c2748 100644 --- a/crates/gpu/src/plan/tests.rs +++ b/crates/gpu/src/plan/tests.rs @@ -335,3 +335,130 @@ fn advanced_blending_falls_back_to_the_cpu() { d.layers.push(Layer::group("g", vec![inner])); assert!(plan(&d).is_err(), "knockout inside a group"); } + +/// Adjustments whose LUT rows the planner caches (#11498), with settings away from identity. +fn row_cached_adjustments() -> Vec { + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + let ch = |g: f32| LevelsChannel { in_black: 0.05, in_white: 0.9, gamma: g, out_black: 0.02, out_white: 0.97 }; + let mut ranges = HueRange::defaults(); + ranges[1].hue = 20.0; + let pts = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.4, output: 0.55 }, CurvePoint { input: 1.0, output: 1.0 }]; + vec![ + Adjustment::Levels { master: ch(1.3), per_channel: [ch(0.8), ch(1.0), ch(1.6)], space: ToneSpace::Rgb, black: Default::default() }, + Adjustment::Curves { master: pts.clone(), per_channel: [pts.clone(), vec![], pts], space: ToneSpace::Rgb, black: vec![], pencil: false }, + Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [10.0, 0.0, -5.0], midtones: [0.0, 5.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: true }, + ] +} + +#[test] +fn cached_adjustment_rows_match_freshly_built_tables() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for a in row_cached_adjustments() { + let fresh: Vec> = match &a { + Adjustment::Levels { .. } | Adjustment::Curves { .. } => adjust::tone_luts_depth(&a, Some(depth)).into_iter().take(3).collect(), + Adjustment::HueSaturation { ranges, .. } => adjust::hue_range_tables(ranges).into_iter().collect(), + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { + let curves = adjust::BalanceCurve::rgb([*shadows, *midtones, *highlights], *preserve_luminosity); + (0..3).map(|ch| (0..4096).map(|k| curves[ch].eval(k as f32 / 4095.0)).collect()).collect() + } + _ => panic!("not a row-cached adjustment"), + }; + // The first plan may fill the cache, the second reads it: both give the fresh rows. + for _ in 0..2 { + let (_, _, rows) = adjustment_program(&a, Transfer::Srgb, depth, ColorMode::Rgb); + let rows = rows.expect("LUT rows"); + if matches!(a, Adjustment::Levels { .. }) && depth == SampleType::F32 { + continue; // The analytic float Levels kernel takes coefficients, not tables. + } + assert_eq!(rows.len(), fresh.len(), "{} at {depth:?}", a.label()); + for (row, f) in rows.iter().zip(&fresh) { + assert!(row.iter().zip(f).all(|(x, y)| x.to_bits() == y.to_bits()), "{} at {depth:?}", a.label()); + } + } + } + } +} + +#[test] +fn row_keys_cover_exactly_what_the_rows_are_built_from() { + use photocraft_doc::adjust::LevelsChannel; + let key = |a: &Adjustment, depth| lower::row_key(a, depth).expect("a row-cached adjustment"); + let [levels, curves, hue_sat, balance] = <[Adjustment; 4]>::try_from(row_cached_adjustments()).expect("four adjustments"); + // The same settings give the same key on every plan, so later plans read the stored rows. + for a in [&levels, &curves, &hue_sat, &balance] { + assert_eq!(key(a, SampleType::U8), key(&a.clone(), SampleType::U8), "{}", a.label()); + } + // Levels and Curves round to the document's quantum, so their keys hold the depth. + assert_ne!(key(&levels, SampleType::U8), key(&levels, SampleType::U16)); + assert_ne!(key(&curves, SampleType::U16), key(&curves, SampleType::F32)); + // Hue/Saturation range tables and Color Balance curves don't depend on depth. + assert_eq!(key(&hue_sat, SampleType::U8), key(&hue_sat, SampleType::F32)); + assert_eq!(key(&balance, SampleType::U8), key(&balance, SampleType::U16)); + // Dragging the master Hue/Saturation sliders leaves the range tables (and their entry) alone. + let Adjustment::HueSaturation { ranges, .. } = &hue_sat else { panic!("Hue/Saturation") }; + let master = Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 12.0, colorize: false, ranges: ranges.clone() }; + assert_eq!(key(&hue_sat, SampleType::U8), key(&master, SampleType::U8)); + let mut moved = ranges.clone(); + moved[4].bounds[2] += 1.0; + let moved = Adjustment::HueSaturation { hue: 0.0, saturation: 10.0, lightness: 0.0, colorize: false, ranges: moved }; + assert_ne!(key(&hue_sat, SampleType::U8), key(&moved, SampleType::U8)); + // Values `==` can't tell apart still get their own rows. + let black = |out_black: f32| { + let ch = LevelsChannel { out_black, ..Default::default() }; + Adjustment::Levels { master: ch.clone(), per_channel: [ch.clone(), ch.clone(), ch], space: ToneSpace::Rgb, black: Default::default() } + }; + assert_ne!(key(&black(0.0), SampleType::U8), key(&black(-0.0), SampleType::U8)); + // Every field the builders read changes the key. + let Adjustment::Curves { master, per_channel, black, .. } = &curves else { panic!("Curves") }; + let pencil = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: true }; + let lab = Adjustment::Curves { master: master.clone(), per_channel: per_channel.clone(), space: ToneSpace::Lab, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&pencil, SampleType::U8)); + assert_ne!(key(&curves, SampleType::U8), key(&lab, SampleType::U8)); + let mut nudged = master.clone(); + nudged[1].output += 0.01; + let point = Adjustment::Curves { master: nudged, per_channel: per_channel.clone(), space: ToneSpace::Rgb, black: black.clone(), pencil: false }; + assert_ne!(key(&curves, SampleType::U8), key(&point, SampleType::U8), "a curve point's value"); + let Adjustment::ColorBalance { shadows, midtones, highlights, .. } = balance.clone() else { panic!("Color Balance") }; + let plain = Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity: false }; + assert_ne!(key(&balance, SampleType::U8), key(&plain, SampleType::U8)); + // Levels and Curves don't share keys with each other, and adjustments without rows have none. + assert_ne!(key(&Adjustment::identity_levels(), SampleType::U8), key(&Adjustment::identity_curves(), SampleType::U8)); + assert_eq!(lower::row_key(&Adjustment::Vibrance { vibrance: 10.0, saturation: 0.0 }, SampleType::U8), None); +} + +#[test] +fn planning_reads_cached_rows_instead_of_rebuilding_them() { + use photocraft_compose::lut_rows; + use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel}; + // Settings no other test plans, so the planted rows are this test's own; the few keys this + // test binary adds can't evict them from the shared cache. + let mut ranges = HueRange::defaults(); + ranges[2].hue = 33.0; + let point = |input, output| CurvePoint { input, output }; + let adjustments = [ + Adjustment::Levels { + master: LevelsChannel { gamma: 1.37, ..Default::default() }, + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: Default::default(), + }, + Adjustment::Curves { + master: vec![point(0.0, 0.0), point(0.3, 0.61), point(1.0, 1.0)], + per_channel: Default::default(), + space: ToneSpace::Rgb, + black: vec![], + pencil: false, + }, + Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges }, + Adjustment::ColorBalance { shadows: [17.0, 0.0, 0.0], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true }, + ]; + for (i, a) in adjustments.iter().enumerate() { + // Rows no builder makes: the planner returns them only if it reads the cache. + let planted = vec![[0.25 + i as f32 / 8.0; lut_rows::ROW_LEN]; 3]; + let key = lower::row_key(a, SampleType::U8).expect("a row-cached adjustment"); + assert!(lut_rows::cached(key, || planted.clone()) == planted, "{}: a key no other test uses", a.label()); + let (_, _, rows) = adjustment_program(a, Transfer::Srgb, SampleType::U8, ColorMode::Rgb); + assert!(rows.as_ref() == Some(&planted), "{}: the plan rebuilt rows the cache held", a.label()); + } +} diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index f0527c8156..e84b2571e4 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -7,6 +7,40 @@ use super::{ Compositor, DeviceHealth, DocId, Document, Fault, HashMap, Kernel, LayerContent, Plan, Rect, Role, TILE_SIZE, Unsupported, fx, plan, prefix, timing, }; +/// Classification of a synchronous compositor pipeline build failure. +/// +/// Only a recognized shader compiler diagnostic is considered deterministic. Every other +/// error, including generic validation, internal, and out-of-memory errors, is conservative +/// and must be treated as transient or unknown by callers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildFailureKind { + /// A known compiler diagnostic means the same shader is expected to fail on relaunch. + DeterministicShaderCompilation, + /// The failure may depend on device state, resource pressure, or driver behavior. + TransientOrUnknown, +} + +/// A pipeline build error with a conservative retry classification. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildFailure { + pub kind: BuildFailureKind, + pub message: String, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BuildFailure {} + +fn classify_build_diagnostic(diagnostic: &str) -> BuildFailureKind { + // FXC's X3507 diagnostic is a concrete shader compilation failure observed when + // compiling generated compositor shaders. Do not classify generic validation text. + if diagnostic.contains("X3507") { BuildFailureKind::DeterministicShaderCompilation } else { BuildFailureKind::TransientOrUnknown } +} + impl Compositor { /// Create a compositor with the default accumulation format ([`ACC_FORMAT`]). pub fn new(device: &wgpu::Device) -> Self { @@ -26,17 +60,53 @@ impl Compositor { /// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed /// to have worked. pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result { - match first_error(device, || Self::build(device, acc_format)) { + match first_error(device, || Self::build(device, acc_format, false)) { ((comp, None), None) => Ok(comp), ((_, Some(e)), _) | (_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))), } } + /// Build pipelines using a dedicated Rayon pool instead of the process-wide pool. + /// + /// This keeps shader compilation from occupying workers needed by CPU compositing. + /// If the pool cannot be created, pipeline enumeration runs sequentially on the calling + /// thread. The outer and per-pipeline wgpu error scopes are kept on their respective + /// build threads. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + Self::try_new_with_format_threads_classified(device, acc_format, threads).map_err(|e| Unsupported(e.message)) + } + + /// Build the compositor pipelines with an explicit failure classification for callers + /// that cache deterministic failures across launches. Only recognized shader compiler + /// diagnostics are marked deterministic; all other wgpu errors are transient or unknown. + #[cfg(not(target_arch = "wasm32"))] + pub fn try_new_with_format_threads_classified(device: &wgpu::Device, acc_format: wgpu::TextureFormat, threads: usize) -> Result { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads.max(1)).thread_name(|index| format!("photocraft-gpu-build-{index}")).build(); + let built = match pool { + Ok(pool) => pool.install(|| first_error(device, || Self::build(device, acc_format, false))), + Err(e) => { + log::warn!("GPU compositor: couldn't create dedicated build pool; building sequentially: {e}"); + first_error(device, || Self::build(device, acc_format, true)) + } + }; + match built { + ((comp, None), None) => Ok(comp), + ((_, Some(e)), None) => { + let kind = classify_build_diagnostic(&e.to_string()); + Err(BuildFailure { kind, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + (_, Some(e)) => { + Err(BuildFailure { kind: BuildFailureKind::TransientOrUnknown, message: format!("couldn't build the compositor's pipelines: {e}") }) + } + } + } + /// Create a compositor whose accumulation/render-target format is `acc_format`. Pass /// `Rgba16Float` on adapters that can't render to `Rgba32Float` (e.g. some Intel Vulkan drivers), /// which otherwise panics at render-pipeline creation. See `Compositor::preferred_acc_format`. pub fn new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Self { - let (comp, error) = Self::build(device, acc_format); + let (comp, error) = Self::build(device, acc_format, false); if let Some(e) = error { log::error!("GPU compositor: couldn't build its pipelines: {e}"); } @@ -54,7 +124,7 @@ impl Compositor { /// while the cache still held it, and dropping the device then aborted the process. So each /// job has its own vertex entry point and fragment shaders ([`build_jobs`]), and builds of /// several compositors take turns. - fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option) { + fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat, sequential: bool) -> (Self, Option) { static BUILDING: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _building = BUILDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); @@ -98,7 +168,7 @@ impl Compositor { }; // Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float // render targets) can't: then layer effects use the CPU compositor and the rest stays here. - let built = fx::par_map(jobs, |job| { + let build_one = |job: super::kit::BuildJob| { job.keys .into_iter() .map(|key| { @@ -106,7 +176,8 @@ impl Compositor { (key, built, error) }) .collect::>() - }); + }; + let built: Vec<_> = if sequential { jobs.into_iter().map(build_one).collect() } else { fx::par_map(jobs, build_one) }; let built = built.into_iter().flatten(); let (mut pipelines, mut maps) = (HashMap::new(), Vec::new()); let (mut error, mut map_error) = (None, None); @@ -548,3 +619,16 @@ impl Compositor { self.timer.as_mut().and_then(timing::GpuTimer::take) } } + +#[cfg(test)] +mod build_failure_tests { + use super::{BuildFailureKind, classify_build_diagnostic}; + + #[test] + fn only_concrete_shader_compiler_diagnostic_is_deterministic() { + assert_eq!(classify_build_diagnostic("error X3507: loop control variable must be integer"), BuildFailureKind::DeterministicShaderCompilation); + assert_eq!(classify_build_diagnostic("Validation Error: invalid bind group"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("out of memory"), BuildFailureKind::TransientOrUnknown); + assert_eq!(classify_build_diagnostic("shader compilation failed"), BuildFailureKind::TransientOrUnknown); + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 09fe6129e7..adca9ef4ef 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -426,3 +426,9 @@ fn colour_managed_tiles_match_the_per_pixel_conversion() { fn grid_rounds_out() { assert_eq!(tile_grid(Rect::new(0, 0, 300, 256)), Rect::new(0, 0, 512, 256)); } + +#[test] +fn shader_fingerprint_is_fnv1a_of_embedded_source() { + assert_eq!(super::fnv1a(b"hello"), 0xa430_d846_80aa_bd0b); + assert_eq!(super::shader_fingerprint(), super::fnv1a(super::SHADER.as_bytes())); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index ba4c368c5d..dcd86482db 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -49,6 +49,8 @@ imagesize = "0.13" # already in the tree through the UI's icon loader; text shaping needs the fonts feature set. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } +# The SVG fallback font search keeps each font file's character map (already in the tree through usvg). +ttf-parser = { version = "0.25", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/export_estimate.rs b/crates/io/src/export_estimate.rs new file mode 100644 index 0000000000..61836b07f8 --- /dev/null +++ b/crates/io/src/export_estimate.rs @@ -0,0 +1,118 @@ +//! A rough upper bound of the memory an export needs, so a caller can refuse a save that cannot +//! fit before starting it (#10024): on the web a failed allocation aborts the module and the +//! document is lost. + +use std::collections::HashSet; + +use photocraft_doc::{Document, LayerContent, SmartSource}; +use photocraft_geom::TILE_SIZE; + +/// Bytes per pixel of the compositor's working buffer (`RGBA`, `f32`), filled when a flat format +/// is written. +const COMPOSITE_BYTES_PER_PX: u64 = 16; + +/// Formats that keep the layers: their model is built from the layer pixels, not a composite. +fn keeps_layers(ext: &str) -> bool { + matches!(ext, "psd" | "psb" | "pcraft" | "ora" | "kra" | "xcf") +} + +/// Formats whose writer fills a composite of the canvas: every flat format, and OpenRaster for +/// its `mergedimage.png`. +fn fills_composite(ext: &str) -> bool { + !keeps_layers(ext) || ext == "ora" +} + +/// The extension of `name_or_ext` (a file name, path or bare extension), lowercase. +fn extension(name_or_ext: &str) -> String { + let file = name_or_ext.rsplit(['/', '\\']).next().unwrap_or(name_or_ext); + file.rsplit_once('.').map_or(file, |(_, ext)| ext).to_ascii_lowercase() +} + +/// The bytes exporting `doc` to `name_or_ext` may need before anything it allocates can fail +/// cleanly: the output model, about one copy of every layer's and mask's pixel tiles plus the +/// embedded smart-object files (each distinct one counted once); a flat format and OpenRaster +/// also fill a composite of the canvas, and PSD/PSB their merged image ([`merged_image_bytes`]). +/// The encoded file is not counted: its buffer is reserved fallibly (`PsdFile::to_bytes`), so if +/// it doesn't fit the save fails with an error rather than trapping, and counting it would refuse +/// saves that fit. Saturates; never panics. +pub fn estimate_export_bytes(doc: &Document, name_or_ext: &str) -> u64 { + let tile_bytes = (TILE_SIZE as u64) * (TILE_SIZE as u64) * doc.pixel_format().bytes_per_pixel().max(1) as u64; + let mut seen = HashSet::new(); + let mut total = 0u64; + for (_, _, layer) in doc.walk() { + let tiles = layer.surface().map_or(0, |s| s.tile_count()) + layer.mask.as_ref().map_or(0, |m| m.surface.tile_count()); + total = total.saturating_add((tiles as u64).saturating_mul(tile_bytes)); + if let LayerContent::Smart(so) = &layer.content + && let SmartSource::Embedded { bytes, .. } = &so.source + && seen.insert(so.contents_id) + { + total = total.saturating_add(bytes.len() as u64); + } + } + let mut need = total; + let ext = extension(name_or_ext); + if fills_composite(&ext) { + need = need.saturating_add(canvas_px(doc).saturating_mul(COMPOSITE_BYTES_PER_PX)); + } + if matches!(ext.as_str(), "psd" | "psb") { + need = need.saturating_add(merged_image_bytes(doc)); + } + need +} + +fn canvas_px(doc: &Document) -> u64 { + u64::from(doc.size.width).saturating_mul(u64::from(doc.size.height)) +} + +/// PSD and PSB always write a canvas-sized merged image: the real composite with maximum +/// compatibility, a white placeholder without it. Its planes (colour channels plus alpha, in the +/// document's sample size) and their encoded copy are held together while the file is built. +fn merged_image_bytes(doc: &Document) -> u64 { + let fmt = doc.pixel_format(); + let per_px = (fmt.mode.color_channels() as u64 + 1).saturating_mul(fmt.sample.bytes() as u64); + canvas_px(doc).saturating_mul(per_px).saturating_mul(2) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Layer, SmartObject}; + use photocraft_geom::Affine; + use std::sync::Arc; + + fn doc_with_smart_object(len: usize) -> Document { + let mut doc = Document::new("t", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let source = SmartSource::Embedded { file_name: "a.png".into(), bytes: Arc::new(vec![0; len]) }; + doc.layers.push(Layer::new("so", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None)))); + doc + } + + #[test] + fn counts_embedded_files_once_and_adds_a_composite_for_flat_formats() { + let doc = doc_with_smart_object(10 << 20); + // RGB 8-bit: three colour planes and alpha, held raw and encoded. + let merged = 64 * 64 * 4 * 2; + assert_eq!(estimate_export_bytes(&doc, "x.psd"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "/a/b/x.PSD"), (10 << 20) + merged); + assert_eq!(estimate_export_bytes(&doc, "x.pcraft"), 10 << 20); + assert_eq!(estimate_export_bytes(&doc, "png"), (10 << 20) + 64 * 64 * 16); + // OpenRaster keeps the layers but also writes a merged image, flattened first. + assert_eq!(estimate_export_bytes(&doc, "x.ora"), (10 << 20) + 64 * 64 * 16); + } + + #[test] + fn a_shared_source_is_counted_once() { + let mut doc = doc_with_smart_object(1 << 20); + let dup = doc.layers[0].clone(); + doc.layers.push(dup); + assert_eq!(estimate_export_bytes(&doc, "psd"), (1 << 20) + 64 * 64 * 4 * 2); + } + + #[test] + fn an_empty_document_needs_next_to_nothing() { + let doc = Document::new("t", photocraft_geom::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); + assert_eq!(estimate_export_bytes(&doc, "psd"), 10 * 10 * 4 * 2, "only the merged image"); + assert_eq!(estimate_export_bytes(&doc, "pcraft"), 0); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6cb0aafc84..fe85d88ce1 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,7 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +pub mod export_estimate; #[cfg(any(unix, windows))] mod exr_native; pub mod exr_parts; @@ -86,6 +87,7 @@ pub mod vector_preview; mod xcf; pub mod xml; +pub use export_estimate::estimate_export_bytes; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; @@ -950,7 +952,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). /// Valid plain SVG is recognized by its XML content; an otherwise unknown `.svg` is passed to the /// SVG parser for a useful malformed-file error, and `.svgz` disambiguates gzip streams. -fn is_svg_input(name: &str, bytes: &[u8]) -> bool { +pub fn is_svg_input(name: &str, bytes: &[u8]) -> bool { // Keep the SVG extension as a fallback for malformed files so the SVG parser can return an // actionable SVG error. A recognized raster signature still wins over a misleading `.svg` // suffix (for example, a WebP downloaded as Firefox_logo.svg). diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index f2165dbb87..e40c782ef0 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -20,10 +20,11 @@ pub const MAX_SIDE: u32 = 160; /// Size of the header in front of the JFIF data. pub const HEADER_LEN: usize = 28; -/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`], converted to -/// sRGB through the document's profile, matted over white and JPEG-compressed. +/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`] with its spot inks +/// printed over it, converted to sRGB through the document's profile, matted over white and +/// JPEG-compressed. pub(crate) fn resource(doc: &Document) -> Result { - let mut buf = photocraft_compose::thumbnail_buffer(doc, MAX_SIDE); + let mut buf = photocraft_compose::multichannel::thumbnail_with_inks(doc, MAX_SIDE); let (w, h) = (buf.rect.width(), buf.rect.height()); if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || buf.px.len() != (w as usize) * (h as usize) { return Err(IoError::Unsupported("the image preview could not be rendered".into())); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 0728035530..ba8900fb87 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -105,6 +105,19 @@ const MAX_PEAK_PIXEL_BYTES: u64 = 3 << 29; const MAX_NODES: usize = 2_000; /// Nesting past which a subtree is rasterised instead of walked (bounded recursion). const MAX_RECURSION: usize = 200; +/// Most filter work beyond the ordinary a drawing may ask of resvg, in pixel reads (see +/// [`filter_cost`]): about ten seconds in a release build (resvg manages roughly 200 million reads +/// a second, and its filters can't be interrupted). +const MAX_FILTER_COST: u64 = 2_000_000_000; +/// Reads per pixel any primitive may take for free (a 16 × 16 morphology window or kernel, 16 +/// turbulence octaves), so ordinary filters open on any canvas. +const ORDINARY_FILTER_READS: u64 = 256; +/// Most elements [`filter_cost`] visits, counting a shared mask, clip path or pattern once per +/// use, as resvg renders it: patterns that fill and stroke with the next one double the work at +/// each level, so a few dozen levels would keep the check (and the render) busy for hours. +const MAX_FILTER_VISITS: usize = 10_000_000; +/// One `feTurbulence` octave per pixel, in pixel reads (it measures about 15 times a read). +const TURBULENCE_OCTAVE_COST: u64 = 16; /// Deepest element nesting accepted. The XML parser recurses once per level, so a file nested /// a few thousand deep (70 KB of ``) overflows a 2 MB thread stack, an abort no guard can /// catch; real drawings stay under a hundred levels. @@ -268,15 +281,78 @@ fn in_memory(id: usvg::fontdb::ID, db: &mut Arc) -> usvg loaded.into_iter().find(|&n| db.face_source(n).is_some_and(|(_, i)| i == index)).unwrap_or(id) } +/// Most `cmap` table bytes [`has_char`] keeps; past it a face is asked on disk (slow, still right). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_CMAP_BYTES: usize = 64 << 20; + +/// Whether face `face` maps `c` to a glyph, as usvg's own check decides it. usvg reads a font +/// file's whole font for every face it asks, so one character no font has re-read every +/// installed font once per text element (430 ms each with 273 faces). A file face's `cmap` table +/// is kept instead, read once per process; a file that doesn't load isn't remembered. +#[cfg(not(target_arch = "wasm32"))] +fn has_char(db: &usvg::fontdb::Database, face: &usvg::fontdb::FaceInfo, c: char) -> bool { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::{Mutex, PoisonError}; + use ttf_parser::{Face, Tag, cmap}; + type Cmaps = (HashMap<(PathBuf, u32), Arc>>, usize); + static CMAPS: OnceLock> = OnceLock::new(); + let maps = || CMAPS.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let in_cmap = + |table: &[u8]| cmap::Table::parse(table).is_some_and(|t| t.subtables.into_iter().any(|s| s.is_unicode() && s.glyph_index(u32::from(c)).is_some())); + let usvg_check = |data: &[u8], index| Face::parse(data, index).is_ok_and(|f| f.glyph_index(c).is_some()); + let usvg::fontdb::Source::File(path) = &face.source else { + return db.with_face_data(face.id, usvg_check).unwrap_or(false); + }; + let key = (path.clone(), face.index); + if let Some(table) = maps().0.get(&key).cloned() { + return in_cmap(&table); + } + // usvg's check parses the whole face first: one that doesn't parse has no characters. Its + // `cmap` is the last such table record (a linear walk, unsorted directories included). + let cmap_of = |data: &[u8], index| { + let face = Face::parse(data, index).ok()?; + let raw = face.raw_face(); + let record = raw.table_records.into_iter().filter(|r| r.tag == Tag::from_bytes(b"cmap")).last(); + let table = record.and_then(|r| { + let start = usize::try_from(r.offset).ok()?; + raw.data.get(start..start.checked_add(usize::try_from(r.length).ok()?)?) + }); + Some(table.unwrap_or_default().to_vec()) + }; + let Some(table) = db.with_face_data(face.id, cmap_of).flatten() else { + return false; + }; + let found = in_cmap(&table); + let mut maps = maps(); + // An import that missed at the same time may have kept it already. + if !maps.0.contains_key(&key) && maps.1.saturating_add(table.len()) <= MAX_CACHED_CMAP_BYTES { + maps.1 = maps.1.saturating_add(table.len()); + maps.0.insert(key, Arc::new(table)); + } + found +} + +/// usvg's fallback choice (the first face, in database order, not used yet, sharing the base +/// face's style, weight or stretch and having `c`), asked through [`has_char`]. +#[cfg(not(target_arch = "wasm32"))] +fn fallback_face(c: char, used: &[usvg::fontdb::ID], db: &usvg::fontdb::Database) -> Option { + let base = db.face(*used.first()?)?; + db.faces() + .filter(|f| !used.contains(&f.id)) + .filter(|f| base.style == f.style || base.weight == f.weight || base.stretch == f.stretch) + .find(|f| has_char(db, f, c)) + .map(|f| f.id) +} + /// usvg's font choice, with the chosen faces read from memory ([`in_memory`]). fn font_resolver() -> usvg::FontResolver<'static> { #[cfg(not(target_arch = "wasm32"))] { let select = usvg::FontResolver::default_font_selector(); - let fallback = usvg::FontResolver::default_fallback_selector(); usvg::FontResolver { select_font: Box::new(move |font, db| select(font, db).map(|id| in_memory(id, db))), - select_fallback: Box::new(move |c, used, db| fallback(c, used, db).map(|id| in_memory(id, db))), + select_fallback: Box::new(|c, used, db| fallback_face(c, used, db).map(|id| in_memory(id, db))), } } #[cfg(target_arch = "wasm32")] @@ -316,7 +392,7 @@ fn options(fontdb: Arc, depth_left: usize) -> usvg::Opti Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)), _ => return None, }; - if nesting_depth(&xml, depth_left) > depth_left { + if !nesting_depth(&xml, depth_left).is_ok_and(|depth| depth <= depth_left) { return None; } return default_resolver(mime, xml, opts); @@ -353,7 +429,7 @@ fn parse(bytes: &[u8]) -> Result { /// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one). fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Result { let xml = inflate(bytes)?; - let depth = nesting_depth(&xml, MAX_XML_DEPTH); + let depth = nesting_depth(&xml, MAX_XML_DEPTH)?; if depth > MAX_XML_DEPTH { return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep"))); } @@ -365,10 +441,19 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul // re-checks the gzip magic and would decompress a second time without our size limit if // the inflated content happened to start with a nested gzip stream. let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; - let tree = usvg::Tree::from_str(text, &opt).map_err(|e| IoError::Svg(e.to_string()))?; + let tree = guarded(|| usvg::Tree::from_str(text, &opt)) + .ok_or_else(|| IoError::Svg("the SVG parser failed on this drawing".into()))? + .map_err(|e| IoError::Svg(e.to_string()))?; Ok(Parsed { tree, fontless_text }) } +/// `f` under `catch_unwind`, `None` when it panics: usvg, resvg and tiny-skia panic on some +/// malformed drawings, which must fail like any unreadable file, not crash (as the HEIF and +/// JPEG XL decoders are guarded). Where a panic aborts (wasm) this changes nothing. +fn guarded(f: impl FnOnce() -> T) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok() +} + const MAX_BOUNDED_SVG_BYTES: u64 = 16 << 20; const MAX_EMBEDDED_SVG_DEPTH: usize = 32; @@ -605,7 +690,7 @@ fn parse_image_bounded_with( if left.is_none() { return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))); } - let nesting = nesting_depth(&xml, xml_left); + let nesting = nesting_depth(&xml, xml_left)?; if nesting > xml_left { return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep"))); } @@ -622,11 +707,11 @@ fn parse_image_bounded_with( // The bounded inflate above is the only decompression step. from_data would detect a // nested gzip payload and inflate it again without the source-byte limit. let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; - let tree = usvg::Tree::from_str(text, &opt); + let tree = guarded(|| usvg::Tree::from_str(text, &opt)); if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() { return Err(IoError::Svg(error)); } - let tree = tree.map_err(|e| IoError::Svg(e.to_string()))?; + let tree = tree.ok_or_else(|| IoError::Svg("the SVG parser failed on this drawing".into()))?.map_err(|e| IoError::Svg(e.to_string()))?; // Include nested SVG trees too. Paint servers can reuse one parsed raster in many // drawing nodes, so the work estimate must not assume every source is decoded once. let nodes = u64::try_from(count_nodes_with_limit(tree.root(), true, usize::MAX, false)).unwrap_or(u64::MAX); @@ -784,8 +869,10 @@ fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result` or `/>`. Malformed markup is only counted -/// loosely: the parser rejects it afterwards. -pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { +/// loosely: the parser rejects it afterwards. The parser expands the DTD's entities after this +/// count, so an entity holding markup (a `<`) is an error: it could nest deeper than anything +/// counted here. A `<` there stays text. +pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> Result { let after = |from: usize, pat: &[u8]| -> usize { xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len())) }; @@ -807,6 +894,44 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { } (xml.len(), false) }; + // The byte after the DOCTYPE starting at `from`: its internal subset `[...]` is walked + // declaration by declaration, so a quote in a comment there can't hide the markup after it. + let doctype_end = |from: usize| -> Result { + let (mut quote, mut j) = (None, from); + loop { + let Some(&b) = xml.get(j) else { return Ok(xml.len()) }; + j = j.saturating_add(1); + match quote { + Some(q) if b == q => quote = None, + Some(_) => {} + None if b == b'"' || b == b'\'' => quote = Some(b), + None if b == b'>' => return Ok(j), + None if b == b'[' => break, + None => {} + } + } + while let Some(rest) = xml.get(j..).filter(|r| !r.is_empty()) { + j = if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"`, quotes or not. + after(j.saturating_add(2), b">") + } else if rest.starts_with(b"]") { + return Ok(tag_end(j.saturating_add(1)).0); + } else { + j.saturating_add(1) + }; + } + Ok(xml.len()) + }; let (mut depth, mut deepest, mut i) = (0usize, 0usize, 0usize); while let Some(p) = xml.get(i..).and_then(|s| s.iter().position(|&b| b == b'<')) { let at = i.saturating_add(p); @@ -817,6 +942,8 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize { after(at.saturating_add(9), b"]]>") } else if rest.starts_with(b"") + } else if rest.starts_with(b" usize { depth = depth.saturating_add(1); deepest = deepest.max(depth); if deepest > limit { - return deepest; + return Ok(deepest); } } end }; } - deepest + Ok(deepest) } /// The document canvas for a drawing: its size in px, scaled down to [`MAX_SIDE`] when needed. @@ -1007,6 +1134,7 @@ fn import_parsed_with_limits( warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string()); } let cv = canvas(&tree, &mut warnings); + check_filter_cost(&tree, cv.scale)?; let nodes = count_nodes(tree.root(), max_cached_pixels.is_some()); let over_cache_budget = if let Some(budget) = max_cached_pixels { let area = cv.size.area(); @@ -1040,13 +1168,16 @@ fn import_parsed_with_limits( } let (w, h) = (cv.size.width, cv.size.height); if let Some(mut pixmap) = pixmap(w, h) { - resvg::render(&tree, global, &mut pixmap.as_mut()); - doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + if guarded(|| resvg::render(&tree, global, &mut pixmap.as_mut())).is_some() { + doc.layers.push(Layer::new("Background", LayerContent::Raster(surface(&pixmap, doc.bounds(), fmt)))); + } else { + conv.note("raster-panic", "SVG: the drawing could not be rendered".to_string()); + } } } else { doc.layers = conv.convert_children(tree.root().children(), 0, 0, tree.root().abs_transform()); } - if doc.layers.is_empty() { + if doc.layers.is_empty() && !conv.noted.contains(&"raster-panic") { conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string()); } Ok(ImportResult { document: doc, warnings: conv.warnings, source_read_only: false, preview_only: false }) @@ -1112,12 +1243,15 @@ pub fn rasterize_rgba8(bytes: &[u8]) -> Result<(u32, u32, Vec), IoError> { Ok((pixmap.width(), pixmap.height(), px)) } -/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up. +/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up, refused +/// when its filters would cost too much at that scale. fn render(tree: &usvg::Tree, k: f32) -> Result { + check_filter_cost(tree, k)?; let side = |v: f32| ((v * k).ceil() as u32).max(1); let (w, h) = (side(tree.size().width()), side(tree.size().height())); let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?; - resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut()); + guarded(|| resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut())) + .ok_or_else(|| IoError::Svg("the drawing could not be rendered".into()))?; Ok(pixmap) } @@ -1176,6 +1310,92 @@ fn count_nodes_with_limit(root: &usvg::Group, include_text: bool, limit: usize, n } +/// The work beyond [`ORDINARY_FILTER_READS`] per pixel that resvg's filters would do to render +/// the drawing through `base` (drawing units to canvas px), in pixel reads: its morphology reads +/// the whole radius window per pixel, convolution the whole kernel, and turbulence runs every +/// octave (usvg doesn't clamp `numOctaves`). Masks, clip paths, patterns and embedded drawings +/// count too, at the scale of the element that uses them, as resvg renders them. Past `visits` +/// elements, or with a referenced root nested past [`MAX_RECURSION`], the drawing counts as too +/// costly. +fn filter_cost(root: &usvg::Group, base: tiny_skia::Transform, rec: usize, visits: &mut usize) -> u64 { + use usvg::filter::Kind; + let mut cost = 0u64; + let mut stack: Vec<&usvg::Group> = vec![root]; + while let Some(g) = stack.pop() { + let ts = base.pre_concat(g.abs_transform()); + let (sx, sy) = ts.get_scale(); + for f in g.filters() { + let Some(r) = f.rect().transform(ts) else { continue }; + // Float-to-int casts saturate, so a huge region counts as huge. + let (w, h) = (r.width().ceil() as u64, r.height().ceil() as u64); + let window = |radius: f32, s: f32, side: u64| ((radius * s).ceil() as u64).saturating_mul(2).min(side); + for p in f.primitives() { + let reads = match p.kind() { + Kind::ConvolveMatrix(c) => u64::from(c.matrix().columns()) * u64::from(c.matrix().rows()), + Kind::Morphology(m) => window(m.radius_x().get(), sx, w).saturating_mul(window(m.radius_y().get(), sy, h)), + Kind::Turbulence(t) => u64::from(t.num_octaves()).saturating_mul(TURBULENCE_OCTAVE_COST), + _ => 0, + }; + cost = cost.saturating_add(w.saturating_mul(h).saturating_mul(reads.saturating_sub(ORDINARY_FILTER_READS))); + } + } + for node in g.children() { + *visits = visits.saturating_sub(1); + if *visits == 0 { + return u64::MAX; + } + if let usvg::Node::Group(child) = node { + stack.push(child); + } + if rec >= MAX_RECURSION { + // usvg keeps referenced roots past this depth and resvg draws them: one the check + // would skip makes the drawing too costly. Ordinary children still count. + let mut skipped = false; + match node { + usvg::Node::Path(p) => { + skipped = [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())] + .into_iter() + .flatten() + .any(|paint| matches!(paint, usvg::Paint::Pattern(_))) + } + _ => node.subroots(|_| skipped = true), + } + if skipped { + return u64::MAX; + } + continue; + } + let world = base.pre_concat(node.abs_transform()); + let mut sub = |root: &usvg::Group, ts: tiny_skia::Transform| cost = cost.saturating_add(filter_cost(root, ts, rec + 1, visits)); + match node { + // resvg draws a pattern's tile at the path's scale times the pattern's own, and + // only that scale (`get_scale`, no skew or rotation): a skewed patternTransform + // whose full-affine bounds are slim still renders a large tile. + usvg::Node::Path(p) => { + for paint in [p.fill().map(|f| f.paint()), p.stroke().map(|s| s.paint())].into_iter().flatten() { + if let usvg::Paint::Pattern(pt) = paint { + let (sx, sy) = world.pre_concat(pt.transform()).get_scale(); + sub(pt.root(), tiny_skia::Transform::from_scale(sx, sy)); + } + } + } + _ => node.subroots(|root| sub(root, world)), + } + } + } + cost +} + +/// An error when the drawing's filters are too costly to render at `scale` (see [`filter_cost`]). +fn check_filter_cost(tree: &usvg::Tree, scale: f32) -> Result<(), IoError> { + if filter_cost(tree.root(), tiny_skia::Transform::from_scale(scale, scale), 0, &mut { MAX_FILTER_VISITS }) > MAX_FILTER_COST { + return Err(IoError::Svg( + "the drawing's filters are too costly to render (a huge morphology radius, convolution kernel or turbulence octave count, or patterns nested too deep)".into(), + )); + } + Ok(()) +} + /// Converts a parsed tree into layers. struct Conv { fmt: PixelFormat, @@ -1502,7 +1722,11 @@ impl Conv { // and shift so the pixmap starts at (x0, y0). let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32)); let ts = canvas.pre_concat(parent).pre_translate(shift.x(), shift.y()); - resvg::render_node(node, ts, &mut pixmap.as_mut())?; + let Some(rendered) = guarded(|| resvg::render_node(node, ts, &mut pixmap.as_mut())) else { + self.note("raster-panic", "SVG: an element that could not be rendered was dropped".to_string()); + return None; + }; + rendered?; let layer = Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt))); let bytes = pixels * self.fmt.bytes_per_pixel() as u64; self.pixel_budget -= bytes; diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index fd7f2713a7..15b6379359 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -166,6 +166,42 @@ fn sniffing_accepts_svg_and_rejects_other_xml() { assert!(!is_svg(b"")); } +/// Ordinary filters, however many and on the largest canvas, never count against the budget. +#[test] +fn ordinary_filters_cost_nothing_on_any_canvas() { + let svg = format!( + "\ + \ + \ + \ + ", + vec!["1"; 25].join(" ") + ); + let tree = parse(svg.as_bytes()).unwrap().tree; + assert_eq!(filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }), 0); + assert!(check_filter_cost(&tree, 1.0).is_ok()); +} + +/// resvg draws a pattern's tile under the scale part of its transform only (`get_scale`): a +/// skewed patternTransform (determinant 1) whose full-affine bounds of a slim filter region are +/// 180 × 181 px still draws that region at 2400 × 180 px, with a morphology window as large, +/// about 2 × 10^11 reads. Never rendered here: only the estimate is checked. +#[test] +fn a_skewed_pattern_filter_counts_at_resvgs_tile_scale() { + let svg = "\ + \ + \ + \ + "; + let tree = parse(svg.as_bytes()).unwrap().tree; + // The tile resvg would allocate: 20 × 20 units at get_scale ≈ (100, 100). + let (sx, sy) = tiny_skia::Transform::from_row(0.0, -0.01, 100.0, 100.0, 0.0, 0.0).get_scale(); + assert!((sx * 20.0).round() >= 2000.0 && (sy * 20.0).round() >= 1999.0, "{sx} {sy}"); + let cost = filter_cost(tree.root(), tiny_skia::Transform::identity(), 0, &mut { MAX_FILTER_VISITS }); + assert!(cost > 50 * MAX_FILTER_COST, "charged only {cost} reads"); + assert!(check_filter_cost(&tree, 1.0).is_err()); +} + #[test] fn quadratics_become_cubics_and_closing_merges_the_start_knot() { let mut pb = tiny_skia::PathBuilder::new(); @@ -451,6 +487,79 @@ fn imports_missing_the_same_font_at_once_share_one_read() { } } +/// #9671: the fallback search for a character the text's font lacks reads each font file's +/// character map once per process, not the whole file on every text element (430 ms an +/// element with 273 installed faces). A second import still finds the fallback face after +/// its file stopped being readable, so it didn't go back to the disk. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_fallback_search_reads_each_font_file_once() { + use std::sync::atomic::{AtomicBool, Ordering}; + static GONE: AtomicBool = AtomicBool::new(false); + fn path() -> std::path::PathBuf { + std::env::temp_dir().join(format!("photocraft-svg-test-fallback-{}.ttf", std::process::id())) + } + fn inter_and_mono_on_disk() -> Arc { + std::fs::write(path(), include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(path()).unwrap(); + if GONE.load(Ordering::SeqCst) { + std::fs::write(path(), b"not a font any more").unwrap(); + } + Arc::new(db) + } + // Inter has no box-drawing characters; JetBrains Mono has. + let svg = "I\u{2500}I"; + let outlines = |parsed: Parsed| { + let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers; + let groups = layers.into_iter().filter_map(|l| if let LayerContent::Group(g) = l.content { Some(g) } else { None }); + groups.flat_map(|g| g.children).filter_map(|l| if let LayerContent::Shape(s) = l.content { Some(s.path) } else { None }).collect::>() + }; + let first = parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap(); + let mono = + first.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); + let first = outlines(first); + GONE.store(true, Ordering::SeqCst); + let second = outlines(parse_with(svg.as_bytes(), inter_and_mono_on_disk).unwrap()); + let _ = std::fs::remove_file(path()); + assert!(first.iter().any(|p| !p.subpaths.is_empty()), "the text has outlines"); + assert!(second == first, "the second import set the character in the same fallback face"); +} + +/// #9671: the kept `cmap` is the one usvg reads (the last such record of a linear walk), also +/// in a font whose table directory isn't sorted by tag, where a binary search misses it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_fallback_search_reads_the_cmap_of_an_unsorted_font() { + fn inter_and_unsorted_mono_on_disk() -> Arc { + let mut font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf").to_vec(); + let count = usize::from(u16::from_be_bytes([font[4], font[5]])); + // The `cmap` record moves to the end of the directory. + let mut records: Vec> = font[12..12 + count * 16].chunks(16).map(<[u8]>::to_vec).collect(); + let at = records.iter().position(|r| r.starts_with(b"cmap")).unwrap(); + let cmap = records.remove(at); + records.push(cmap); + font.splice(12..12 + count * 16, records.concat()); + let raw = ttf_parser::RawFace::parse(&font, 0).unwrap(); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"cmap")).is_none(), "a binary search misses the cmap"); + assert!(raw.table(ttf_parser::Tag::from_bytes(b"name")).is_some(), "fontdb still finds the name"); + let path = std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id())); + std::fs::write(&path, font).unwrap(); + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec()); + db.load_font_file(&path).unwrap(); + Arc::new(db) + } + let svg = b"I\xE2\x94\x80I"; + let parsed = parse_with(svg, inter_and_unsorted_mono_on_disk).unwrap(); + let _ = std::fs::remove_file(std::env::temp_dir().join(format!("photocraft-svg-test-unsorted-{}.ttf", std::process::id()))); + let mono = + parsed.tree.fontdb().faces().any(|f| f.families.iter().any(|(n, _)| n == "JetBrains Mono") && matches!(f.source, usvg::fontdb::Source::Binary(_))); + assert!(mono, "the box-drawing character falls back to JetBrains Mono"); +} + /// #9406: moving a font collection into memory keeps its faces in their order, so the /// fallback search for a later text still finds the face it found on disk first. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/io/src/xml.rs b/crates/io/src/xml.rs index 13e02004d1..423c8c8555 100644 --- a/crates/io/src/xml.rs +++ b/crates/io/src/xml.rs @@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result if opts.allow_dtd && internal_subset(text.as_bytes()) { return Err("internal DTD subsets aren't supported".into()); } - if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH) > MAX_DEPTH { + if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH).map_err(|e| e.to_string())? > MAX_DEPTH { return Err(format!("elements are nested more than {MAX_DEPTH} levels deep")); } Document::parse_with_options(text, opts).map_err(|e| e.to_string()) diff --git a/crates/io/tests/it/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs index 7445bbf757..be6fbd360b 100644 --- a/crates/io/tests/it/psd_thumbnail.rs +++ b/crates/io/tests/it/psd_thumbnail.rs @@ -151,3 +151,29 @@ fn the_document_profile_is_honoured() { let mid = px[25 * 50 + 25]; assert!(near(mid, [0.735; 3], 0.03), "{mid:?}"); } + +/// The thumbnail shows the document's spot inks over its composite, as the reference editor's +/// does (psd-tools cmyk-alpha-spot.psd: its spot-only text is in the preview, not in the merged +/// image). +#[test] +fn spot_inks_print_in_the_thumbnail() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut d = doc(mode, SampleType::U8, 64, 32, [1.0, 1.0, 1.0, 1.0], 1.0); + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut ink = photocraft_raster::Surface::new(fmt); + ink.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 32), &[1.0]); + let teal = photocraft_color::Color::rgb(0.0, 157.0 / 255.0, 165.0 / 255.0); + d.channels.push(photocraft_doc::AlphaChannel { spot: Some((teal, 0.0)), ..photocraft_doc::AlphaChannel::new("light teal", ink) }); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let (w, _, px) = thumbnail(&bytes).unwrap(); + let (inked, bare) = (px[(16 * w + 8) as usize], px[(16 * w + 56) as usize]); + assert!(near(inked, [0.0, 157.0 / 255.0, 165.0 / 255.0], 0.06), "{mode:?} {inked:?}"); + assert!(near(bare, [1.0, 1.0, 1.0], 0.04), "{mode:?} {bare:?}"); + // The merged process planes stay free of the ink: white (no CMYK ink is stored as 255 too). + let merged = PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap(); + let plane = 64 * 32; + for c in 0..mode.color_channels() { + assert_eq!(merged[c * plane + 16 * 64 + 8], 255, "{mode:?} merged channel {c}"); + } + } +} diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 5c695d1526..3f0ba53462 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -411,6 +411,53 @@ fn deeply_nested_markup_is_an_error_not_a_stack_overflow() { assert_eq!(r.document.layers.len(), 1); } +/// The parser expands a DTD's internal entities after the depth check: an entity holding +/// markup, or a quote in a DTD comment or declaration that made the check skip ahead, hid 20 000 nested groups +/// from it and overflowed the stack again. Both are errors now; same small stack as above. +#[test] +fn a_dtd_cannot_hide_nesting_from_the_depth_check() { + let deep = |open: &str, close: &str| format!("{}{}", open.repeat(20_000), close.repeat(20_000)); + let with_dtd = |subset: &str, body: &str| format!("{body}"); + let cases = [ + ("entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("single-quoted entity", with_dtd(&format!("", deep("", "")), "&a;")), + ("quote in a comment", with_dtd("", &format!("{}'", deep("", "")))), + ("quote in an element declaration", with_dtd("", &format!("{}'", deep("", "")))), + ( + "entity between quoted attribute lists", + with_dtd(&format!("", deep("", "")), "&a;"), + ), + ("entity between quoted notations", with_dtd(&format!("", deep("", "")), "&a;")), + ]; + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + for (name, svg) in &cases { + let gz = gzip(svg.as_bytes()); + for bytes in [svg.as_bytes(), gz.as_slice()] { + let e = import("deep.svg", bytes).err().unwrap_or_else(|| panic!("{name} opened")); + assert!(matches!(&e, IoError::Svg(_)), "{name}: {e}"); + } + } + }) + .unwrap() + .join() + .unwrap(); +} + +/// Text-only entities, as Illustrator declares its namespaces, still open, `<` included: +/// the parser keeps a character reference in an entity as text. +#[test] +fn text_entities_in_a_dtd_still_open() { + let r = open( + " ?> ]>\ + &c;", + ); + assert_eq!(r.document.layers.len(), 1); + assert!(close(px(&r.document, 4, 4), [1.0, 0.0, 0.0, 1.0])); +} + /// The depth check reads the markup, not the parser's tree: it must not count `<` in comments, /// CDATA, processing instructions or the DOCTYPE, nor end a tag at a `>` inside quotes. #[test] @@ -476,6 +523,136 @@ fn rasterize_renders_the_whole_drawing_at_a_scale() { assert!(svg::rasterize(huge.as_bytes(), 64.0).is_err(), "a render past the pixel cap is an error, not an allocation"); } +/// resvg's morphology, convolution and turbulence cost pixels × radius², × order² and × octaves: +/// such a drawing is refused up front instead of rendering for minutes. +#[test] +fn filters_too_costly_to_render_are_refused_not_run() { + let drawing = |side: u32, fe: &str| { + format!( + "{fe}" + ) + }; + let kernel = vec!["1"; 100 * 100].join(" "); + // Masks, patterns and embedded drawings filter at the scale of the element using them: a + // 20-unit radius-10 morphology drawn 100× larger. + let f = ""; + let inner = format!("{f}"); + let embedded = base64_data_url("image/svg+xml", inner.as_bytes()); + for svg in [ + drawing(2000, ""), + drawing(2000, &format!("")), + drawing(2000, ""), + format!( + "{f}" + ), + format!( + "{f}" + ), + format!(""), + ] { + let head: String = svg.chars().take(300).collect(); + let e = import("x.svg", svg.as_bytes()).err().unwrap_or_else(|| panic!("opened: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + assert!(svg::rasterize(svg.as_bytes(), 1.0).is_err(), "rendered: {head}"); + // The budgeted paths (smart-object contents and their renders) refuse it too. + let e = svg::import_svg_bounded_images("x.svg", svg.as_bytes(), 1 << 24).err().unwrap_or_else(|| panic!("opened bounded: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + let e = svg::rasterize_bounded(svg.as_bytes(), 1.0, 1 << 24).err().unwrap_or_else(|| panic!("rendered bounded: {head}")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("filter")), "{e}"); + } + // Ordinary filters still open and render. + let ordinary = drawing(500, ""); + assert!(!open(&ordinary).document.layers.is_empty()); + assert!(svg::rasterize(ordinary.as_bytes(), 1.0).is_ok()); + // The smart-object render counts its scale: cheap at 1×, refused at 40×. + let small = drawing(100, ""); + assert!(svg::rasterize(small.as_bytes(), 1.0).is_ok()); + assert!(svg::rasterize(small.as_bytes(), 40.0).is_err()); +} + +/// Patterns that fill and stroke with the next one double resvg's work at every level: past the +/// check's element budget the drawing is refused, not walked (or rendered) for hours. +#[test] +fn patterns_nested_too_deep_are_refused_not_walked() { + let chain = |depth: usize| { + let defs: String = (0..depth) + .map(|i| { + let paint = if i + 1 < depth { format!("url(#p{})", i + 1) } else { "red".into() }; + format!("") + }) + .collect(); + format!("{defs}") + }; + let deep = chain(24); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a 2^24-use pattern chain")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A few levels still open. + assert!(!open(&chain(4)).document.layers.is_empty()); +} + +/// usvg keeps a chain of single-use patterns past the check's recursion cutoff and resvg draws +/// every one of them: a costly filter at the end of the chain is refused, not rendered unseen. +#[test] +fn a_costly_filter_past_the_pattern_recursion_cutoff_is_refused() { + let chain = |depth: usize, fe: &str| { + let defs: String = (0..depth) + .map(|i| { + let body = if i + 1 < depth { + format!("", i + 1) + } else { + "".into() + }; + format!("{body}") + }) + .collect(); + format!("{fe}{defs}") + }; + // Just over the budget (about 3.5 billion reads), so a regression fails in seconds, not hours. + let fe = ""; + assert!(import("x.svg", chain(4, fe).as_bytes()).is_err(), "the filter alone is over the budget"); + let deep = chain(210, fe); + let e = import("x.svg", deep.as_bytes()).err().unwrap_or_else(|| panic!("opened a costly filter 210 patterns deep")); + assert!(matches!(&e, IoError::Svg(m) if m.contains("too costly")), "{e}"); + assert!(svg::rasterize(deep.as_bytes(), 1.0).is_err()); + // A short chain with an ordinary filter still opens, and so do groups nested past the cutoff. + assert!(!open(&chain(4, "")).document.layers.is_empty()); + let groups = + format!("{}{}", "".repeat(250), "".repeat(250)); + assert!(!open(&groups).document.layers.is_empty()); +} + +#[test] +fn drawings_the_svg_renderer_panics_on_are_errors_not_crashes() { + // usvg panics laying out an embedded SVG image of a zero-ish width. + let nested = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10'/%3E%3C/svg%3E"; + let bad_image = format!(""); + let e = import("image.svg", bad_image.as_bytes()).err().unwrap_or_else(|| panic!("opened")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + // The budgeted parse (placed and embedded drawings) is guarded too. + let e = svg::import_svg_bounded_images("image.svg", bad_image.as_bytes(), 1 << 24).err().unwrap_or_else(|| panic!("opened")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + // resvg panics rendering an offset past the pixmap's size: that element is dropped with a + // warning, the rest of the drawing still opens. + let offset = "\ + "; + let svg = format!("{offset}"); + let r = open(&svg); + assert_eq!(r.document.layers.len(), 1, "{:?}", r.document.layers.iter().map(|l| &l.name).collect::>()); + assert!(matches!(r.document.layers[0].content, LayerContent::Shape(_))); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + // The same drawing as a smart object, and past the element limit (opened as one pixel layer). + let e = svg::rasterize(svg.as_bytes(), 1.0).err().unwrap_or_else(|| panic!("rendered")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + let e = svg::rasterize_bounded(svg.as_bytes(), 1.0, 1 << 24).err().unwrap_or_else(|| panic!("rendered")); + assert!(matches!(e, IoError::Svg(_)), "{e}"); + let many = format!("{offset}{}", "".repeat(2_000)); + let r = open(&many); + assert!(r.document.layers.is_empty()); + assert!(r.warnings.iter().any(|w| w.contains("could not be rendered")), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("nothing to draw")), "not an empty drawing: {:?}", r.warnings); +} + #[test] fn an_svg_round_trips_through_pcraft_as_shapes() { let r = open(&format!( diff --git a/crates/paint/examples/bench_brush.rs b/crates/paint/examples/bench_brush.rs index efd9c010f1..d6d5567188 100644 --- a/crates/paint/examples/bench_brush.rs +++ b/crates/paint/examples/bench_brush.rs @@ -29,6 +29,7 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { let start = Instant::now(); let mut r = StrokeRenderer::new(brush, Some(fmt), 1.0); let mut worst = 0.0f64; + let (mut worst_push, mut worst_comp) = (0.0f64, 0.0f64); let mut chunks = 0; let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); for ch in pts.chunks(16) { @@ -40,13 +41,15 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { t_push += tp; t_comp += tt - tp; worst = worst.max(tt); + worst_push = worst_push.max(tp); + worst_comp = worst_comp.max(tt - tp); chunks += 1; } r.finish(); r.composite(&pre, &mut s, None, false, false); let total = start.elapsed().as_secs_f64() * 1000.0; println!( - "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms", + "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms worst push/chunk {worst_push:>6.2} ms worst composite/chunk {worst_comp:>6.2} ms", r.dab_count(), total / chunks as f64 ); diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 8b024de5a6..268ca52b95 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1874,6 +1874,44 @@ fn large_tips_render_the_same_in_parallel_and_serially() { } } +/// The general loop (sampled tips, per-tip textures) paints the same pixels on the thread pool +/// and serially, at 8-bit, 16-bit and float depth. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_strokes_render_the_same_in_parallel_and_serially_at_every_depth() { + let tip = GrayTile::from_fn(16, 16, |x, y| if (x + y) % 3 == 0 { 1.0 } else { 0.4 }); + let mut textured = BrushSettings { size: 120.0, hardness: 0.3, ..brush() }; + textured.texture = Texture { enabled: true, pattern: Pattern::Tile(tip.clone()), each_tip: true, ..Default::default() }; + let strokes = [BrushSettings { size: 120.0, tip: TipShape::Sampled(tip), color: [0.9, 0.2, 0.1, 1.0], ..brush() }, textured]; + let full = Rect::new(0, 0, 400, 200); + let formats = [PixelFormat::RGBA8, PixelFormat::RGBA8.with_sample(SampleType::U16), PixelFormat::RGBA32F]; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + for fmt in formats { + for (i, b) in strokes.iter().enumerate() { + let render = |parallel_from: usize| -> Vec { + crate::render::set_parallel_min_pixels(parallel_from); + let mut s = Surface::new(fmt); + s.fill_rect(full, &[0.5, 0.5, 0.5, 1.0]); + render_stroke(&mut s, b, &line(80.0, 320.0, 100.0), None, false, 1.0); + s.read_region(full) + }; + let parallel = render(0); + let serial = render(usize::MAX); + assert_eq!(parallel.len(), serial.len()); + let diff = parallel.iter().zip(&serial).filter(|(a, b)| a.to_bits() != b.to_bits()).count(); + assert_eq!(diff, 0, "stroke {i} at {fmt:?}: {diff} pixels differ between the parallel and the serial render"); + // Colour channels only: the opaque background's alpha (1.0) is always 0.5 away from 0.5. + assert!(parallel.as_chunks::<4>().0.iter().any(|px| px[..3].iter().any(|v| (*v - 0.5).abs() > 0.1)), "stroke {i} at {fmt:?} painted something"); + } + } +} + /// A CMYK document with its own profile: blend modes other than Normal convert each pixel through /// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the /// serial path gives (the workers enter the caller's CMYK space too). diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 6a679decf6..66cd382876 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -48,6 +48,11 @@ pub const COV_TILE: i32 = 64; #[cfg(not(target_arch = "wasm32"))] pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; +/// The general loop (sampled tips, noise, textures, wet edges) reaches the thread pool at a quarter +/// of the pixels: at about 9-12 ns a pixel, threads already halve a dab of 16,000 pixels or more. +#[cfg(not(target_arch = "wasm32"))] +const GENERAL_LOOP_COST: usize = 4; + /// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). #[cfg(not(target_arch = "wasm32"))] static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); @@ -325,16 +330,6 @@ impl BrushContext { } None => (0, 1.0, 1.0, 1.0), }; - // A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. - let to_tip = |dx: f32, dy: f32| { - let (mut ux, mut uy) = (dx, -dy); - if let Some((ax, ay, k)) = proj { - let t = (ux * ax + uy * ay) * k; - ux += t * ax; - uy += t * ay; - } - ((ux * cs + uy * sn) * fx, (-ux * sn + uy * cs) * fy) - }; // A tip a few pixels across takes each pixel's covered area (a 4×4 grid inside it): // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. @@ -360,75 +355,59 @@ impl BrushContext { rasterize_round(d, rect, out, profile, aliased); return; } - for yy in 0..h { - let y = rect.y0 + yy as i32; - let dy = y as f32 + 0.5 - cy; - for xx in 0..w { - let x = rect.x0 + xx as i32; - let dx = x as f32 + 0.5 - cx; - let (u, v) = to_tip(dx, dy); - let (mut val, rn) = match mips { - // Block: the whole-pixel square, hard (the Eraser's Block mode). - _ if aliased && b.square => { - if dx.abs() >= r || dy.abs() >= r { - continue; - } - (1.0, 0.0) - } - None if small => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= small_reach2 { - continue; - } - let mut sum = 0.0; - for sy in SUBPIXEL { - for sx in SUBPIXEL { - let (su, sv) = to_tip(dx + sx, dy + sy); - let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample takes the tip's own edge, so the covered area - // changes smoothly with the dab's position. - sum += profile.falloff(sd); - } - } - (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) - } - None => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= reach2 { - continue; - } - let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; - (val, dd / rm) - } - Some(m) => { - let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); - if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { - continue; - } - let s = m.sample_clamped(level, px / tw, py / th); - let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; - (val, 1.0 - s) - } - }; - if val <= 0.0 { - continue; - } - if noise && val < 1.0 { - let n = hash2(x, y, NOISE_SALT); - let hard = if n < val { 1.0 } else { 0.0 }; - val += (hard - val) * 0.7; - } - if tex_tip { - val = texture_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); - } - if wet { - val *= wet_factor(rn); - } - if let Some(slot) = depth.as_deref_mut().and_then(|dp| dp.get_mut(yy * w + xx)) { - *slot = (1.0 - rn).clamp(0.0, 1.0); - } - out[yy * w + xx] = val * d.alpha; + if w == 0 { + return; + } + let tip = GeneralTip { + ctx: self, + d, + mips, + x0: rect.x0, + y0: rect.y0, + aliased, + small, + noise, + tex_tip, + wet, + cx, + cy, + sn, + cs, + fx, + fy, + proj, + r, + ro, + rm, + profile, + reach2, + small_reach2, + level, + inv_scale, + tw, + th, + }; + // Rows are independent (noise is a pure hash of position, everything else is read-only), + // so the pixels are the same whether they run in parallel or not. The serial path stays a + // plain loop over an inlined row (a closure called from several sites cost a small dab a + // tenth of its time). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len().saturating_mul(GENERAL_LOOP_COST)) { + use rayon::prelude::*; + if let Some(dp) = depth.as_deref_mut() { + out.par_chunks_exact_mut(w).zip(dp.par_chunks_exact_mut(w)).enumerate().for_each(|(yy, (row, drow))| tip.row(yy, row, Some(drow))); + } else { + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row, None)); + } + return; + } + if let Some(dp) = depth { + for (yy, (row, drow)) in out.chunks_exact_mut(w).zip(dp.chunks_exact_mut(w)).enumerate() { + tip.row(yy, row, Some(drow)); + } + } else { + for (yy, row) in out.chunks_exact_mut(w).enumerate() { + tip.row(yy, row, None); } } } @@ -1361,6 +1340,130 @@ impl RoundTip { } } +/// The general rasterizer's per-dab state, one row at a time (see [`BrushContext::rasterize_into`]). +#[derive(Clone, Copy)] +struct GeneralTip<'a> { + ctx: &'a BrushContext, + d: &'a Dab, + mips: Option<&'a Mips>, + x0: i32, + y0: i32, + aliased: bool, + small: bool, + noise: bool, + tex_tip: bool, + wet: bool, + cx: f32, + cy: f32, + sn: f32, + cs: f32, + fx: f32, + fy: f32, + proj: Option<(f32, f32, f32)>, + r: f32, + ro: f32, + rm: f32, + profile: TipProfile, + reach2: f32, + small_reach2: f32, + level: usize, + inv_scale: f32, + tw: f32, + th: f32, +} + +impl GeneralTip<'_> { + /// A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. + #[inline(always)] + fn to_tip(self, dx: f32, dy: f32) -> (f32, f32) { + let (mut ux, mut uy) = (dx, -dy); + if let Some((ax, ay, k)) = self.proj { + let t = (ux * ax + uy * ay) * k; + ux += t * ax; + uy += t * ay; + } + ((ux * self.cs + uy * self.sn) * self.fx, (-ux * self.sn + uy * self.cs) * self.fy) + } + + /// Row `yy` of the dab's rectangle into `row` (and its depth into `depth_row`). + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32], mut depth_row: Option<&mut [f32]>) { + let Self { + ctx, d, mips, x0, y0, aliased, small, noise, tex_tip, wet, cx, cy, r, ro, rm, profile, reach2, small_reach2, level, inv_scale, tw, th, .. + } = self; + let b = &ctx.brush; + let y = y0 + yy as i32; + let dy = y as f32 + 0.5 - cy; + for (xx, dst) in row.iter_mut().enumerate() { + let x = x0 + xx as i32; + let dx = x as f32 + 0.5 - cx; + let (u, v) = self.to_tip(dx, dy); + let (mut val, rn) = match mips { + // Block: the whole-pixel square, hard (the Eraser's Block mode). + _ if aliased && b.square => { + if dx.abs() >= r || dy.abs() >= r { + continue; + } + (1.0, 0.0) + } + None if small => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= small_reach2 { + continue; + } + let mut sum = 0.0; + for sy in SUBPIXEL { + for sx in SUBPIXEL { + let (su, sv) = self.to_tip(dx + sx, dy + sy); + let sd = ((su * ro).powi(2) + sv * sv).sqrt(); + // Each sample takes the tip's own edge, so the covered area + // changes smoothly with the dab's position. + sum += profile.falloff(sd); + } + } + (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) + } + None => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= reach2 { + continue; + } + let dd = d2.sqrt(); + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; + (val, dd / rm) + } + Some(m) => { + let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); + if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { + continue; + } + let s = m.sample_clamped(level, px / tw, py / th); + let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; + (val, 1.0 - s) + } + }; + if val <= 0.0 { + continue; + } + if noise && val < 1.0 { + let n = hash2(x, y, NOISE_SALT); + let hard = if n < val { 1.0 } else { 0.0 }; + val += (hard - val) * 0.7; + } + if tex_tip { + val = texture_combine(b.texture.mode, val, ctx.texture_at(x, y), d.depth); + } + if wet { + val *= wet_factor(rn); + } + if let Some(slot) = depth_row.as_deref_mut().and_then(|dp| dp.get_mut(xx)) { + *slot = (1.0 - rn).clamp(0.0, 1.0); + } + *dst = val * d.alpha; + } + } +} + #[cfg(test)] mod reference; diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index e10fdd099f..c7dc366bb2 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -322,6 +322,153 @@ fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { } } +/// Dabs that take the general loop, each with the context and flags that select a feature. +#[cfg(not(target_arch = "wasm32"))] +fn general_loop_cases() -> Vec<(&'static str, BrushContext, Dab, bool, bool)> { + let tip = GrayTile::from_fn(16, 12, |x, y| ((x + 2 * y) % 7) as f32 / 6.0 + 0.1); + let base = || BrushSettings { hardness: 0.25, ..Default::default() }; + let dab = |angle: f32, roundness: f32, proj_scale: f32| { + let mut d = Dab::round(Point::new(50.3, 60.7), 40.0, 0.8); + d.angle = angle; + d.roundness = roundness; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + d + }; + let ctx = |b: BrushSettings| BrushContext::new(&b); + vec![ + ("sampled", ctx(BrushSettings { tip: TipShape::Sampled(tip.clone()), ..base() }), dab(0.4, 1.0, 1.0), false, false), + ( + "per-tip texture", + ctx({ + let mut b = base(); + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = Pattern::Tile(tip.clone()); + b + }), + dab(0.0, 1.0, 1.0), + false, + false, + ), + ("noise", ctx(BrushSettings { noise: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges with depth", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, true), + ("rotated ellipse", ctx(base()), dab(0.7, 0.4, 1.0), false, false), + ("projection", ctx(base()), dab(0.0, 1.0, 0.6), false, false), + ( + "dual tip", + ctx({ + let mut b = base(); + b.dual_brush.enabled = true; + b.dual_brush.tip = TipShape::Sampled(tip.clone()); + b + }), + dab(0.2, 1.0, 1.0), + true, + false, + ), + ] +} + +#[cfg(not(target_arch = "wasm32"))] +struct RestoreThreshold; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for RestoreThreshold { + fn drop(&mut self) { + set_parallel_min_pixels(DEFAULT_PARALLEL_MIN_PIXELS); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() +} + +/// Rows of the general loop run on the thread pool from a quarter of the pixels the round path +/// needs, and give the bits the serial loop gives. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_rows_match_serially_and_in_parallel() { + let _restore = RestoreThreshold; + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + assert!(rect.height() > 8, "{label}: many rows"); + let run = |threshold: usize| { + set_parallel_min_pixels(threshold); + let (mut out, mut depth) = (vec![0.75; 7], vec![0.25; 5]); + ctx.rasterize_into(&d, dual, rect, &mut out, with_depth.then_some(&mut depth)); + if !with_depth { + depth.clear(); + } + (out, depth) + }; + let (par, par_depth) = run(0); + let (ser, ser_depth) = run(usize::MAX); + assert_eq!(bits(&par), bits(&ser), "{label}: coverage differs between parallel and serial"); + assert_eq!(bits(&par_depth), bits(&ser_depth), "{label}: depth differs between parallel and serial"); + assert!(par.iter().any(|v| *v != 0.0), "{label}: painted something"); + if with_depth { + assert!(par_depth.iter().any(|v| *v != 0.0), "{label}: depth written"); + } + } +} + +/// The parallel general loop gives the bits of the frozen serial loop. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_general_loop_matches_the_frozen_reference() { + let _restore = RestoreThreshold; + set_parallel_min_pixels(0); + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + let (mut actual, mut expected) = (Vec::new(), Vec::new()); + let (mut actual_depth, mut expected_depth) = (Vec::new(), Vec::new()); + ctx.rasterize_into(&d, dual, rect, &mut actual, with_depth.then_some(&mut actual_depth)); + ctx.rasterize_reference(&d, dual, rect, &mut expected, with_depth.then_some(&mut expected_depth)); + assert_eq!(bits(&actual), bits(&expected), "{label}: coverage differs from the reference"); + assert_eq!(bits(&actual_depth), bits(&expected_depth), "{label}: depth differs from the reference"); + assert!(actual.iter().any(|v| *v != 0.0), "{label}: painted something"); + } +} + +/// Empty rects and non-finite dabs return normally from the general loop at either threshold. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_survives_empty_rects_and_nan_dabs() { + let _restore = RestoreThreshold; + let tip = GrayTile::from_fn(8, 5, |x, y| ((x + y) % 7) as f32 / 6.0); + let sampled = BrushContext::new(&BrushSettings { tip: TipShape::Sampled(tip), ..Default::default() }); + let plain = BrushContext::new(&BrushSettings::default()); + for threshold in [0, usize::MAX] { + set_parallel_min_pixels(threshold); + for ctx in [&sampled, &plain] { + for rect in [Rect::new(0, 0, 0, 5), Rect::new(0, 0, 5, 0), Rect::new(0, 0, 0, 0)] { + for with_depth in [false, true] { + let (mut out, mut depth) = (vec![0.5; 9], vec![0.5; 9]); + ctx.rasterize_into(&Dab::round(Point::new(2.0, 2.0), 6.0, 1.0), false, rect, &mut out, with_depth.then_some(&mut depth)); + assert!(out.is_empty()); + if with_depth { + assert!(depth.is_empty()); + } + } + } + } + for field in 0..2 { + let mut d = Dab::round(Point::new(0.0, 0.0), 6.0, 1.0); + if field == 0 { + d.radius = f32::NAN; + } else { + d.angle = f32::NAN; + } + let mut out = Vec::new(); + sampled.rasterize_into(&d, false, Rect::new(-4, -4, 4, 4), &mut out, None); + assert_eq!(out.len(), 64); + } + } +} + // Keep the pre-cache scalar coverage equations independent of TipProfile. fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { let (r0, s) = tip_shape(radius, hardness); diff --git a/crates/psd/tests/it/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs index 49ad412e7c..1823900372 100644 --- a/crates/psd/tests/it/roundtrip.rs +++ b/crates/psd/tests/it/roundtrip.rs @@ -313,3 +313,21 @@ fn spec_order_density_only_mask_round_trips_as_a_model() { assert_eq!(g.layers()[0].decode_channel(-2, 8, Version::Psd).unwrap(), vec![64]); assert_eq!(g.to_bytes().unwrap(), b, "byte stability"); } + +/// Embedded data (smart-object files, and the like) is large next to the headers, so the output +/// is reserved at its exact size up front: no growth by doubling, which on the web is a single +/// unrecoverable multi-gigabyte `realloc` (#10024). +#[test] +fn to_bytes_reserves_the_whole_output_up_front() { + for version in [Version::Psd, Version::Psb] { + let mut f = testgen::small(version, Compression::Rle); + f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", vec![0x5a; 3 << 20])); + f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 1 << 20])); + let b = f.to_bytes().unwrap(); + assert_eq!(b.capacity(), b.len(), "{version:?}: the output buffer grew past its reserve"); + assert_eq!(b.len() as u64, f.encoded_len().unwrap()); + let p = PsdFile::from_bytes(&b).unwrap(); + assert_eq!(p, f); + assert_eq!(p.to_bytes().unwrap(), b); + } +} diff --git a/crates/raster/src/headroom.rs b/crates/raster/src/headroom.rs new file mode 100644 index 0000000000..9e38b58f31 --- /dev/null +++ b/crates/raster/src/headroom.rs @@ -0,0 +1,167 @@ +//! How much memory can still be allocated, asked of the allocator itself. +//! +//! On the web (`wasm32-unknown-unknown`) a failed infallible allocation aborts the module and the +//! page freezes with no message, and stable Rust cannot turn that into an error. The allocations +//! made with `try_reserve*` do survive, though: the allocator hands back null when the browser +//! refuses to grow the linear memory, and the caller gets an error. So before a big operation +//! (place, resize, re-render a smart object, save) the operation asks [`ensure`] whether its +//! estimated need can be allocated right now, and fails with a message the user can act on +//! instead of trapping part-way. +//! +//! The answer is a trial reservation: one `Vec::try_reserve_exact`, freed at once. It sees the +//! real ceiling of this browser and device (a phone may stop well below 4 GiB) and the +//! fragmentation of the heap, costs nothing per allocation, and needs no allocator wrapper. The +//! freed block returns to the allocator's free list, so the operation reuses it. A reservation +//! is not written to, so the browser commits no physical pages for it. +//! +//! [`below`], which the low-memory warning asks every few seconds, does not reserve anything: +//! wasm linear memory never shrinks, so a periodic trial would raise the module's high-water +//! mark by the amount it asks about, for nothing. It reads the size of linear memory instead. +//! +//! Native targets overcommit, so the question has no useful answer there: every check passes and +//! desktop behaviour is unchanged. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::AllocationError; + +/// Room kept free after an operation's own need: frames, egui, notices and the error path. +pub const RESERVE: u64 = 128 << 20; + +/// Marks [`simulate_available`] as off. +const OFF: u64 = u64::MAX; + +static SIMULATED: AtomicU64 = AtomicU64::new(OFF); + +/// Pretends that only `bytes` can be allocated (`None` stops pretending). The simulation is +/// process-wide and applies on every target. It is for tests and the web build's `?free-mb` +/// diagnostic flag only, so the gates and the low-memory warning can be seen without allocating +/// gigabytes. +pub fn simulate_available(bytes: Option) { + SIMULATED.store(bytes.unwrap_or(OFF), Ordering::SeqCst); +} + +fn simulated() -> Option { + match SIMULATED.load(Ordering::SeqCst) { + OFF => None, + n => Some(n), + } +} + +/// The most linear memory a wasm32 module can address. +#[cfg(target_arch = "wasm32")] +const WASM32_CEILING: u64 = 4 << 30; + +/// `true` when `bytes` can be allocated in one piece right now. Always `true` off the web. +fn can_allocate(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes <= available; + } + trial(bytes) +} + +#[cfg(target_arch = "wasm32")] +fn trial(bytes: u64) -> bool { + let Ok(bytes) = usize::try_from(bytes) else { return false }; + let mut probe: Vec = Vec::new(); + probe.try_reserve_exact(bytes).is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn trial(_bytes: u64) -> bool { + true +} + +/// `Ok` when `need` bytes (plus [`RESERVE`]) can be allocated right now. On the web, this tries +/// to reserve that much and frees it at once. On other targets it always passes (unless a +/// simulation is set). +pub fn ensure(need: u64) -> Result<(), AllocationError> { + let total = need.saturating_add(RESERVE); + let bytes = usize::try_from(total).map_err(|_| AllocationError::SizeOverflow)?; + if can_allocate(total) { Ok(()) } else { Err(AllocationError::NotEnoughMemory { bytes }) } +} + +/// `true` while the module's linear memory is within `bytes` of the 4 GiB wasm32 ceiling (web +/// only; always `false` elsewhere unless a simulation is set). Allocates nothing, unlike +/// [`ensure`]. Linear memory is a high-water mark, so this errs towards `true`: room freed inside +/// the allocator after a peak doesn't count. A browser that stops growing memory below 4 GiB isn't +/// seen either; the operation checks still are. +pub fn below(bytes: u64) -> bool { + if let Some(available) = simulated() { + return bytes > available; + } + linear_memory_left().is_some_and(|left| left < bytes) +} + +#[cfg(target_arch = "wasm32")] +fn linear_memory_left() -> Option { + let used = (core::arch::wasm32::memory_size::<0>() as u64).saturating_mul(64 << 10); + Some(WASM32_CEILING.saturating_sub(used)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn linear_memory_left() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static LOCK: Mutex<()> = Mutex::new(()); + + /// Serializes the tests (the simulation is global) and resets it when each one ends. + struct Guard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + + impl Guard { + fn new() -> Self { + let guard = Guard(LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + simulate_available(None); + guard + } + } + + impl Drop for Guard { + fn drop(&mut self) { + simulate_available(None); + } + } + + const MIB: u64 = 1 << 20; + + #[test] + fn passes_on_native_without_simulation() { + let _g = Guard::new(); + assert!(ensure(10 << 30).is_ok()); + assert!(!below(64 << 30)); + } + + #[test] + fn simulated_room_counts_the_reserve() { + let _g = Guard::new(); + // 300 MiB of room: a 10 MiB need plus the 128 MiB reserve fits, a 200 MiB need does not. + simulate_available(Some(300 * MIB)); + assert!(ensure(10 * MIB).is_ok()); + assert!(matches!(ensure(200 * MIB), Err(AllocationError::NotEnoughMemory { .. }))); + assert!(!below(300 * MIB)); + assert!(below(301 * MIB)); + } + + #[test] + fn huge_needs_do_not_overflow() { + let _g = Guard::new(); + let _ = ensure(u64::MAX); + simulate_available(Some(100 * MIB)); + assert!(ensure(u64::MAX).is_err()); + } + + #[test] + fn simulation_can_be_switched_off() { + let _g = Guard::new(); + simulate_available(Some(0)); + assert!(ensure(0).is_err()); + simulate_available(None); + assert!(ensure(0).is_ok()); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 01b8c7d504..63b55617a7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod headroom; mod interrupt; pub use interrupt::{Cancelled, Interrupt}; pub mod scratch; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0af8e7ac17..26177c5e68 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +chrono = { workspace = true } craft-print = { workspace = true } craft-ui = { workspace = true } photocraft-geom = { workspace = true } diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index efa1300c73..0ba257defd 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -322,9 +322,13 @@ impl Bench { /// Reports a row. `splits` (one per sample, or none) adds the command / refresh split. fn record(&mut self, name: &str, s: &Session, samples: &[f64], splits: &[Split], result: Res<()>) { + self.record_with_gpu_bytes(name, samples, splits, result, self.gpu_bytes(s)); + } + + fn record_with_gpu_bytes(&mut self, name: &str, samples: &[f64], splits: &[Split], result: Res<()>, gpu_bytes: Option) { match result { Ok(()) => { - let mut r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s)); + let mut r = row(name, samples, self.sampler.peak(), gpu_bytes); let mut split = String::new(); if !splits.is_empty() && splits.len() == samples.len() { let cmd: Vec = splits.iter().map(|x| x.cmd_ms).collect(); @@ -404,8 +408,13 @@ struct Sizes { /// A huge document with one painted layer, refreshed zoomed out (#1015, #9330). The row name /// leaves out the size because `--quick` uses a smaller one. huge_doc: (u32, u32), + /// The new document whose compositor builds in the background. + first_document: (u32, u32), } +/// The most `--reps` a run takes: each sample is kept, and a mistyped count must not allocate for them all. +const MAX_REPS: usize = 10_000; + const FULL: Sizes = Sizes { layered: (5472, 3648), big_layer: (4243, 2828), @@ -418,6 +427,7 @@ const FULL: Sizes = Sizes { a4: (2480, 3508), huge_new: (78_740, 39_370), huge_doc: (30_000, 30_000), + first_document: (1920, 1080), }; const QUICK: Sizes = Sizes { @@ -432,6 +442,7 @@ const QUICK: Sizes = Sizes { a4: (1240, 1754), huge_new: (20_000, 10_000), huge_doc: (8_000, 8_000), + first_document: (640, 480), }; /// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke, @@ -498,7 +509,7 @@ fn main() { let args: Vec = std::env::args().collect(); let quick = args.iter().any(|a| a == "--quick"); let sz = if quick { &QUICK } else { &FULL }; - let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).clamp(1, MAX_REPS); let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() }; let no_reuse = args.iter().any(|a| a == "--no-composite-reuse"); if no_reuse && let Some((canvas, _)) = &gpu { @@ -526,7 +537,7 @@ fn main() { // A panic in one group (a crash the scenario found) is reported against the row that was // running, and the other groups still run. type Group = fn(&mut Bench, &Sizes); - let groups: [(&str, Group); 8] = [ + let groups: [(&str, Group); 9] = [ ("layered", layered_scenarios), ("many layers", many_layer_scenarios), ("full-canvas layers", full_canvas_layer_scenarios), @@ -534,6 +545,7 @@ fn main() { ("long operations", job_scenarios), ("algorithms", algorithm_scenarios), ("huge document", huge_document_scenarios), + ("first document", first_document_scenarios), ("redraw area", redraw_area_scenarios), ]; let use_gpu = b.gpu.is_some(); @@ -584,6 +596,79 @@ fn main() { } } +/// Startup costs when a new canvas builds its compositor in the background, as the app does. +fn first_document_scenarios(b: &mut Bench, sz: &Sizes) { + let (w, h) = sz.first_document; + const FIRST: &str = "first document: UI-thread time to first pixels"; + const READY: &str = "first document: GPU compositor ready"; + let Some((_, rs)) = b.gpu.as_ref() else { + println!("GPU compositor startup scenarios skipped: no GPU canvas"); + return; + }; + let rs = rs.clone(); + if rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu { + println!("GPU compositor startup scenarios skipped: software adapter"); + return; + } + for name in [FIRST, READY] { + if !b.wanted(name) { + continue; + } + b.current = Some(name.into()); + b.sampler.reset(); + let mut samples = Vec::with_capacity(b.reps); + let mut failure = None; + for _ in 0..b.reps { + let canvas = GpuCanvas::new(&rs); + let ctx = egui::Context::default(); + let started = Instant::now(); + canvas.start_compositor(&ctx); + let mut session = Session::new(); + if let Err(e) = exec(&mut session, "file.new", json!({"width": w, "height": h, "background": "white"})) { + failure = Some(e); + break; + } + let first_ms = if name == FIRST { + let Some(doc) = session.active().map(|d| &d.doc) else { + failure = Some("file.new did not create a document".into()); + break; + }; + canvas.refresh(doc.id.0, doc, None, None); + Some(started.elapsed().as_secs_f64() * 1000.0) + } else { + None + }; + // Wait before starting the next sample so builds cannot overlap or replace resources. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + if let Some(result) = canvas.take_compositor_completion() { + if let Err((message, _)) = result { + failure = Some(format!("GPU compositor build failed: {message}")); + } + break; + } + if std::time::Instant::now() >= deadline { + failure = Some("timed out waiting for GPU compositor build".into()); + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + if failure.is_some() { + break; + } + samples.push(first_ms.unwrap_or_else(|| started.elapsed().as_secs_f64() * 1000.0)); + } + if let Some(error) = failure { + println!("{name:<52} ERROR {error}"); + b.errors.push((name.into(), error)); + break; + } else { + // Each sample owns a separate canvas that has already been dropped. + b.record_with_gpu_bytes(name, &samples, &[], Ok(()), None); + } + } +} + /// #209 rows on the layered 20 MP document. fn layered_scenarios(b: &mut Bench, sz: &Sizes) { let (w, h) = sz.layered; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index caf9a90314..34c2ef16e1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,7 @@ mod clipboard; mod commands; mod control; mod files; +pub(crate) use files::ensure_memory_to_save; mod frame; mod gpu; mod ime; @@ -89,6 +90,7 @@ impl PhotocraftApp { live_adjust: None, frame: 0, notices_hovered: false, + low_memory: crate::memory_warning::Watch::default(), styled: false, integrated_titlebar: false, custom_titlebar: false, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 93cb879777..df4bc77986 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -355,6 +355,7 @@ impl PhotocraftApp { } self.canvases.retain(|(doc, _), _| ids.contains(doc)); self.navigator_textures.retain(|doc, _| ids.contains(doc)); + self.snapshot_thumbs.retain(|(doc, _), _| ids.contains(doc)); // Closing a document cancels its page work; its GPU pages go with `retain_gpu_documents`. self.streams.retain(|doc, _| ids.contains(doc)); self.view_docs = ids; diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 9833be7e1c..e106a2ecfb 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -538,9 +538,11 @@ impl PhotocraftApp { if self.background_jobs && let Some(save) = self.services.save_file.clone() { + // `start_save` checks the memory itself: other callers reach it directly. return jobs_ui::start_save(self, path, settings.clone(), copy, save, false); } let st = self.session.active().ok_or("no document")?; + ensure_memory_to_save(&st.doc, &path)?; let (doc, written, previous) = (st.doc.id, st.revision, st.saved_revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; @@ -782,6 +784,7 @@ impl PhotocraftApp { }; } let state = self.session.active().ok_or("no document")?; + ensure_memory_to_save(&state.doc, &target)?; let (doc, revision) = (state.doc.id, state.revision); let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&state.doc, &target, &settings)?; @@ -850,6 +853,15 @@ impl PhotocraftApp { } } +/// Refuses a save that won't fit in memory, before any encoding starts (#10024): on the web a +/// failed allocation aborts the module and the unsaved document is lost with it. The document +/// stays open and unchanged, and the message says how to free memory. +pub(crate) fn ensure_memory_to_save(doc: &photocraft_engine::doc::Document, path: &str) -> Result<(), String> { + let name = file_open::display_name(path); + photocraft_engine::memory_gate::ensure_for_save(&format!("save \u{201c}{name}\u{201d}"), photocraft_io::estimate_export_bytes(doc, path)) + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 2d4c074365..eec74bfcc4 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,9 +1,9 @@ use super::{ - PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, file_open, - gpu_canvas, gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices, - paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui, - text_field_menu, theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, - view_cmds, wide_angle_ui, work_area, workspace_ui, + PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, gpu_canvas, + gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices, paint_mouse, + palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui, text_field_menu, + theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, view_cmds, + wide_angle_ui, work_area, workspace_ui, }; impl PhotocraftApp { @@ -53,6 +53,7 @@ impl PhotocraftApp { } self.last_frame_time = now; self.sync_views(); + crate::memory_warning::tick(self, now); crate::type_font_preview::follow_hover(self, ctx); self.check_gpu(ctx); #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] @@ -110,9 +111,7 @@ impl PhotocraftApp { } } // Web drops onto the canvas, read since the last frame: placed below, one at a time. - let to_place: Vec<(String, Vec)> = - self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - self.drop_places.extend(to_place.into_iter().map(|(name, bytes)| file_open::DropPlace::Bytes(name, bytes))); + self.queue_web_places(); // Finder double-click / Open With / Dock drops (macOS open-documents events). self.drain_os_events(ctx); // While files are dragged over the window it gets no pointer events: keep frames coming so diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 055a85e0fd..b38536ab6b 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -33,6 +33,80 @@ impl PhotocraftApp { self.prefs_rt.gpu_style = None; } + /// Begin preparing the compositor after the GPU canvas is initialized. + pub fn start_compositor(&mut self, ctx: &egui::Context) { + self.start_compositor_inner(ctx, false); + } + + pub(crate) fn retry_compositor(&mut self, ctx: &egui::Context) { + self.start_compositor_inner(ctx, true); + } + + fn start_compositor_inner(&mut self, ctx: &egui::Context, retry: bool) { + if self.session.prefs().performance.effective_rendering_mode() == photocraft_engine::prefs::RenderingMode::Cpu { + return; + } + let Some(gpu) = self.gpu.clone() else { return }; + let compiler = self.services.shader_compiler.as_deref().unwrap_or(""); + let key = gpu.compositor_memo_key(compiler); + let mut memo = self.load_gpu_memo(); + let bypass = retry || std::env::var("PHOTOCRAFT_GPU_REBUILD").is_ok_and(|v| v == "1"); + if bypass { + if memo.remove(&key) { + self.save_gpu_memo(&memo); + } + } else if let Some(entry) = memo.get(&key) { + gpu.remember_compositor_failure(&entry.error); + return; + } + gpu.start_compositor(ctx); + } + + fn load_gpu_memo(&mut self) -> crate::gpu_memo::GpuMemo { + self.services + .load_gpu_memo + .as_mut() + .and_then(|load| load()) + .and_then(|text| crate::gpu_memo::GpuMemo::parse(&text).map_err(|e| log::info!("ignoring GPU compositor memo: {e}")).ok()) + .unwrap_or_default() + } + + fn save_gpu_memo(&mut self, memo: &crate::gpu_memo::GpuMemo) { + if let (Ok(text), Some(save)) = (memo.serialize(), self.services.save_gpu_memo.as_mut()) + && let Err(error) = save(&text) + { + log::info!("could not save GPU compositor memo: {error}"); + } + } + + fn record_compositor_completion(&mut self) { + let Some(gpu) = self.gpu.clone() else { return }; + let Some(result) = gpu.take_compositor_completion() else { return }; + // A completed CPU redraw can leave a current texture and a temporary build reason. + // Recompute those documents on their next repaint, including any hidden documents. + for id in self.render_path.compositor_building_docs() { + if let Some(doc) = self.session.documents().iter().find(|st| st.doc.id.0 == id).map(|st| st.doc.id) { + crate::canvas::background_redraw::cancel(self, doc); + self.canvases.remove(&(doc, crate::canvas::GPU_OUTPUT)); + } + } + let compiler = self.services.shader_compiler.as_deref().unwrap_or(""); + let key = gpu.compositor_memo_key(compiler); + let mut memo = self.load_gpu_memo(); + let changed = match result { + Ok(()) => memo.remove(&key), + Err((error, true)) if gpu.fault().is_none() => { + let now: chrono::DateTime = std::time::SystemTime::now().into(); + memo.remember(key, error, now.to_rfc3339()); + true + } + Err(_) => false, + }; + if changed { + self.save_gpu_memo(&memo); + } + } + /// Whether the document canvas currently draws on the GPU (false on the CPU path, and after /// the device was lost). pub fn gpu_active(&self) -> bool { @@ -72,5 +146,6 @@ impl PhotocraftApp { /// the CPU canvas after a device loss and runs the started hook. pub fn check_gpu(&mut self, ctx: &egui::Context) { gpu_status::check(self, ctx); + self.record_compositor_completion(); } } diff --git a/crates/ui-egui/src/app/thumbs.rs b/crates/ui-egui/src/app/thumbs.rs index 7ba8fb1db6..36cf9834f0 100644 --- a/crates/ui-egui/src/app/thumbs.rs +++ b/crates/ui-egui/src/app/thumbs.rs @@ -63,11 +63,18 @@ impl PhotocraftApp { (self.store_thumb(ctx, key, rev, img), frame) } - /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, - /// centred in a square. Thumbnails of `doc`'s snapshots not in `live` are dropped. - pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot, live: &[u64]) -> egui::TextureId { + /// Drops the thumbnails of `doc`'s snapshots not in `live` (deleted ones). Those of closed + /// documents go in `sync_views`. + pub fn retain_snapshot_thumbs(&mut self, doc: DocId, live: &[u64]) { self.snapshot_thumbs.retain(|(d, id), _| *d != doc || live.contains(id)); - if let Some(tex) = self.snapshot_thumbs.get(&(doc, snap.id)) { + } + + /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, + /// centred in a square. + pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot) -> egui::TextureId { + if let Some((shown, tex)) = self.snapshot_thumbs.get(&(doc, snap.id)) + && shown.ptr_eq(&std::sync::Arc::downgrade(&snap.doc)) + { return tex.id(); } let comp = photocraft_compose::thumbnail(&snap.doc, 56); @@ -82,7 +89,7 @@ impl PhotocraftApp { } let tex = ctx.load_texture(format!("snapshot-{}", snap.id), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR); let id = tex.id(); - self.snapshot_thumbs.insert((doc, snap.id), tex); + self.snapshot_thumbs.insert((doc, snap.id), (std::sync::Arc::downgrade(&snap.doc), tex)); id } diff --git a/crates/ui-egui/src/blend_dropdown_groups_tests.rs b/crates/ui-egui/src/blend_dropdown_groups_tests.rs new file mode 100644 index 0000000000..4b5bb069a0 --- /dev/null +++ b/crates/ui-egui/src/blend_dropdown_groups_tests.rs @@ -0,0 +1,101 @@ +//! #5532: the Layers panel's Blend Mode list draws a line between Photoshop's six groups of modes: +//! Normal, Dissolve | Darken … Darker Color | Lighten … Lighter Color | Overlay … Hard Mix | +//! Difference … Divide | Hue … Luminosity. + +use egui::accesskit::Role; +use egui::{Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; + +/// The app at 1440×900 with a new document and a pixel layer above the Background, active. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }); + h.run_steps(8); + h +} + +/// The Layers panel's dropdown: the lowest one (the options bar and the title bar have some too). +fn dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap() +} + +/// The open list's rect (the one foreground area: no other popup is open). +fn open_list(h: &Harness<'_, PhotocraftApp>) -> Option { + h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))) +} + +fn blend(h: &Harness<'_, PhotocraftApp>) -> BlendMode { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().blend +} + +fn steps(h: &Harness<'_, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.past_len() +} + +/// Presses at `at`, holds for `held` frames and releases there. +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, held: usize) { + h.hover_at(at); + h.run_steps(1); + for (pressed, frames) in [(true, held), (false, 4)] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.run_steps(frames); + } +} + +#[test] +fn the_layers_blend_mode_list_has_a_line_between_each_group() { + let mut h = harness(); + let at = dropdown(&h).center(); + click(&mut h, at, 1); + assert!(open_list(&h).is_some(), "the list is open"); + // Each mode's row in the open list: its label's last node (the box shows the chosen one's + // first). Rows scrolled out of the list keep their place in it, so the gaps hold for all. + let rows: Vec = BlendMode::LAYER_MODES + .iter() + .map(|m| h.get_all_by_label(m.label()).map(|n| n.rect()).next_back().unwrap_or_else(|| panic!("{m:?} is in the list"))) + .collect(); + let gaps: Vec = rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect(); + let row_gap = gaps.iter().copied().fold(f32::INFINITY, f32::min); + let lines: Vec<(BlendMode, BlendMode)> = + gaps.iter().enumerate().filter(|(_, g)| **g > row_gap + 2.0).map(|(i, _)| (BlendMode::LAYER_MODES[i], BlendMode::LAYER_MODES[i + 1])).collect(); + assert_eq!( + lines, + [ + (BlendMode::Dissolve, BlendMode::Darken), + (BlendMode::DarkerColor, BlendMode::Lighten), + (BlendMode::LighterColor, BlendMode::Overlay), + (BlendMode::HardMix, BlendMode::Difference), + (BlendMode::Divide, BlendMode::Hue), + ], + "row gaps {gaps:?}" + ); +} + +/// A plain click on the Layers panel's Blend Mode box at 1440×900, released where it was pressed +/// (after one frame, or after a few as a person's click takes), opens the list beside the box and +/// chooses nothing: the list doesn't open over the box, where the release would pick a row +/// (#5495 review of a taller list). +#[test] +fn a_click_on_the_blend_mode_box_opens_the_list_and_changes_nothing() { + for held in [6, 1] { + let mut h = harness(); + let undo = steps(&h); + let button = dropdown(&h); + click(&mut h, button.center(), held); + assert_eq!((blend(&h), steps(&h)), (BlendMode::Normal, undo), "held {held}: the click chose nothing"); + let Some(list) = open_list(&h) else { panic!("held {held}: the list is open") }; + assert!(!list.intersects(button.shrink(1.0)), "held {held}: the list {list:?} leaves the box {button:?} free"); + assert!(list.top() >= 0.0 && list.bottom() <= 900.0, "held {held}: the list {list:?} is in the window"); + } +} diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index 3523267b10..431ad7aea2 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -72,7 +72,7 @@ pub(crate) fn choices() -> Vec<((Option, bool), &'static str)> { fn blend_items(behind: bool, clear: bool) -> Vec { let mut out = Vec::new(); for m in BlendMode::LAYER_MODES { - if matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) { + if crate::panels::starts_blend_group(m) { out.push(Item::Separator); } out.push(Item::Blend(m)); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f75576c2e4..ae4f7a45e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -64,7 +64,6 @@ pub mod zoom_window_fit; pub(crate) use cursors::eyedropper_ring_colors; pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor}; use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair}; -#[cfg(test)] pub(crate) use display::GPU_OUTPUT; use display::texture_key; pub(crate) use display::{cache_key, canvas_display, display_doc, display_image}; @@ -507,7 +506,7 @@ fn paint_canvas( } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). - if let Some(tex) = crate::channel_view::ensure(app, ctx, idx) { + if let Some(tex) = crate::channel_view::ensure(app, ctx, idx, output) { // Rendered from the live preview, so over the area the preview is shown in. let a = shown_area; if view.rotation.abs() > 0.05 { @@ -1595,7 +1594,7 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { - paint_bucket_cursor(ui.ctx(), p) + paint_bucket_cursor(app, painter, p) } } // Over the first anchor, the close-path pointer (#4596), before the pens' own cursors. @@ -1700,9 +1699,14 @@ fn finish_canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut Vie painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the - // drag's next move then extends into what scrolled in. + // drag's next move then extends into what scrolled in. The crop tools keep their own drags: + // they scroll only while the press is the canvas's own (not a panel slider or a scrollbar held + // while a stale crop drag lingers) and the crop tool has it (not a temporary Zoom, which + // freezes the drag). + let crop_drag = + response.is_pointer_button_down_on() && tool == app.ui.tool && (crate::crop_ui::autoscrolls(app) || crate::perspective_crop_ui::dragging(app)); if primary - && app.drag.is_some() + && (app.drag.is_some() || crop_drag) && tool != Tool::Hand && !middle && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) @@ -1963,6 +1967,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::crop_straighten::set_held(app, !ctx.text_edit_focused() && ui.input(|i| i.modifiers.command)); // A tab switched this frame drops the other document's pending crop (#1918). crate::crop_ui::cancel_stale(app); + // A tool picked mid-drag ends the crop gesture before the frame sees the switch, which would + // otherwise drop the untouched default frame under the box being drawn (#9223). The Hand and + // Zoom never reach `tool_event`, so this is the one place that sees every tool. + crate::crop_ui::end_drag_on_leave(app); crate::crop_ui::ensure_frame(app); let mut drawing = crate::crop_ui::active(app); if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) { @@ -2221,12 +2229,12 @@ mod rotation_preview_isolation_tests { .unwrap(); let before = app.session.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); ensure_filter_preview(&mut app, 0, &ctx).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; let st = app.session.active().unwrap(); @@ -2236,16 +2244,16 @@ mod rotation_preview_isolation_tests { delta.clear(); assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; assert_eq!(image.pixels, original); delta.clear(); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); ctx.tex_manager().write().take_delta().clear(); crate::dialogs::cancel(&mut app, dialog).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; assert_eq!(image.pixels, original); @@ -2383,11 +2391,11 @@ mod rotation_preview_isolation_tests { view.set_color_visible(2, false); } let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); - crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; crate::dialogs::confirm(&mut app, dialog).unwrap(); diff --git a/crates/ui-egui/src/canvas/background_redraw.rs b/crates/ui-egui/src/canvas/background_redraw.rs index 003d250ab3..042aac8e36 100644 --- a/crates/ui-egui/src/canvas/background_redraw.rs +++ b/crates/ui-egui/src/canvas/background_redraw.rs @@ -111,6 +111,9 @@ pub(crate) struct Redraw { stop: Arc, started_ms: f64, fallback: Option, + /// Time spent on the failed GPU composite attempts that led to this redraw (ms), recorded once + /// when it ends. + gpu_attempt_ms: f64, /// The state the blocks are drawn from, and what the texels depend on besides it. doc: Arc, display: u64, @@ -286,6 +289,7 @@ pub(crate) fn start( visible: Rect, region: Rect, fallback: Option, + gpu_attempt_ms: f64, ) { let all = blocks(doc.bounds()); let display_key = display.as_deref().map_or(0, |d| d.texture_key); @@ -293,15 +297,21 @@ pub(crate) fn start( // Taken again when a running redraw continues: the edit (or Memory Usage) may have changed it. let cap = keep_cap(app, doc.id); app.kept.trim(cap); - let (mut pending, since, fallback, mut keep) = match app.redraws.remove(&doc.id) { + let (mut pending, since, fallback, gpu_attempt_ms, mut keep) = match app.redraws.remove(&doc.id) { Some(mut old) if old.blocks == all => { // Blocks drawn from another state aren't part of this one's composite. let same = old.display == display_key && old.text_gamma == gamma && (Arc::ptr_eq(&old.doc, &doc) || *old.doc == *doc); let (collected, bytes) = if same && old.collected_bytes <= cap { (std::mem::take(&mut old.collected), old.collected_bytes) } else { (vec![None; all.len()], 0) }; - (std::mem::take(&mut old.pending), (old.started_ms, old.drawn), fallback.or_else(|| old.fallback.take()), (collected, bytes, cap)) + ( + std::mem::take(&mut old.pending), + (old.started_ms, old.drawn), + fallback.or_else(|| old.fallback.take()), + gpu_attempt_ms + old.gpu_attempt_ms, + (collected, bytes, cap), + ) } - _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, (vec![None; all.len()], 0, cap)), + _ => (vec![false; all.len()], (crate::gpu_canvas::now_ms(), 0), fallback, gpu_attempt_ms, (vec![None; all.len()], 0, cap)), }; for (p, b) in pending.iter_mut().zip(&all) { *p |= !b.intersect(®ion).is_empty(); @@ -328,14 +338,14 @@ pub(crate) fn start( } keep = (vec![None; all.len()], 0, keep.2); } - spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, keep }); + spawn(app, ctx, Spawn { doc, display, display_key, text_gamma: gamma, format, visible, blocks: all, pending, since, fallback, gpu_attempt_ms, keep }); } /// 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); + start(app, ctx, gpu, doc, display, visible, Rect::EMPTY, None, 0.0); } } @@ -384,6 +394,7 @@ pub(crate) fn poll(app: &mut PhotocraftApp, gpu: &GpuCanvas, doc: DocId) { } let ms = crate::gpu_canvas::now_ms() - r.started_ms; app.perf.record("background", r.drawn, ms, 0.0); + app.perf.record_gpu_attempt(r.gpu_attempt_ms); crate::notices::slow_cpu_refresh(app, doc.0, ms, r.fallback.as_ref()); } @@ -455,6 +466,7 @@ struct Spawn { /// When it started and the pixels uploaded so far. since: (f64, u64), fallback: Option, + gpu_attempt_ms: f64, /// Blocks collected for [`Kept`], their bytes, and the cap. keep: (Vec>>, u64, u64), } @@ -471,6 +483,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { pending, since: (started_ms, drawn), fallback, + gpu_attempt_ms, keep: (collected, collected_bytes, cap), } = s; let id = doc.id; @@ -512,6 +525,7 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) { stop, started_ms, fallback, + gpu_attempt_ms, doc: kept_doc, display: display_key, text_gamma, diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index ce47c0c003..6176ea0e26 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -599,6 +599,34 @@ fn a_compositor_build_failure_shows_its_reason() { assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|f| f.contains("X3507"))); } +#[test] +fn completed_compositor_build_refreshes_an_unchanged_cached_document() { + let _gpu = photocraft_testkit::gpu_lock(); + for success in [true, false] { + let Some(mut g) = gpu_app() else { return }; + let gpu = g.harness.state().gpu.as_ref().expect("GPU canvas").clone(); + gpu.simulate_compositor_build(); + g.harness.state_mut().run("file.new", json!({"width": 400, "height": 300, "background": "#336699"})).expect("new"); + g.harness.step(); + settle(&mut g); + let doc = g.harness.state().session.active().expect("document").doc.id; + let revision = g.harness.state().session.active().expect("document").revision; + assert_eq!(render_path_of(&g).0, Some("compositorBuilding")); + assert!(gpu.simulate_compositor_completion(success), "test compositor must build"); + g.harness.step(); + settle(&mut g); + g.harness.run_steps(2); + assert_eq!(g.harness.state().session.active().expect("document").revision, revision); + if success { + assert!(on_gpu_canvas(&g), "successful build should replace the temporary reason: {:?}", render_path_of(&g)); + } else { + assert_eq!(render_path_of(&g).0, Some("compositorBuild")); + assert!(g.harness.state().perf.gpu_fallback.as_deref().is_some_and(|s| s.contains("simulated build failure"))); + assert_matches_synchronous(&mut g, doc); + } + } +} + #[test] fn a_canvas_over_the_texture_budget_records_its_reason() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/canvas/channel_view.rs b/crates/ui-egui/src/canvas/channel_view.rs index 4d7602e4a1..6a15fb9bdd 100644 --- a/crates/ui-egui/src/canvas/channel_view.rs +++ b/crates/ui-egui/src/canvas/channel_view.rs @@ -1,11 +1,18 @@ -//! Channel display on the canvas: alpha / spot / Quick Mask colour overlays, single colour -//! channel views (grayscale) and hidden colour channels. +//! Channel display on the canvas: alpha / Quick Mask colour overlays, spot inks printed over the +//! composite, single colour channel views (grayscale) and hidden colour channels. //! //! The document composite (GPU or CPU) is untouched. When the Channels panel shows anything other //! than the plain composite, one extra texture is drawn over it: transparent overlay pixels when -//! the composite stays visible, an opaque picture otherwise. The texture is keyed by document -//! revision and view state and updated per damage rectangle while painting, so nothing is -//! recomposited per frame. +//! the composite stays visible, an opaque picture with hidden colour channels. Visible spot +//! channels of an RGB or CMYK document print over the sampled composite where they have ink +//! (opaque, through the canvas display transform); elsewhere the texture stays transparent, so the +//! normal canvas shows. This is a screen approximation, not a press proof: each ink is its stored +//! display colour blended by the Multichannel overprint formula (multiply at solidity 0, cover at +//! solidity 1) over the sampled composite. A solo spot (all colour channels hidden) shows as its +//! grayscale values instead. The texture is keyed by document revision, view state and display, and +//! updated per damage rectangle while painting, so nothing is recomposited per frame. + +use std::borrow::Cow; use egui::{Color32, TextureOptions}; use photocraft_doc::{AlphaChannel, ColorMode, Document}; @@ -33,6 +40,8 @@ struct SampledComposite { struct CompositeSource<'a> { cached: Option<&'a [[f32; 4]]>, fresh: &'a mut Option>, + /// What printed spot inks go through on their way to the screen. + screen: &'a Screen, } pub struct Cache { @@ -44,7 +53,8 @@ pub struct Cache { /// False while the plain composite shows: the texture is kept (so toggling a view back on /// reuses its allocation) but its pixels are stale. valid: bool, - tex: egui::TextureHandle, + /// `None` while the view draws nothing over the canvas (visible spot channels without ink). + tex: Option, source: Option, } @@ -90,9 +100,80 @@ impl Look { } } +/// What the canvas does to composite values on their way to the screen: the sRGB encoding of +/// linear composites, then the monitor transform (with the soft proof under View › Proof Colors). +/// Spot inks printed over the composite go through it, so they match the canvas around them. +#[derive(Clone, Default)] +pub struct Screen { + pub encode_srgb: bool, + pub transform: Option>, +} + +impl Screen { + /// How the canvas shows `doc` on `display` (`None`: the main window's). + pub fn of(app: &PhotocraftApp, doc: &Document, display: Option) -> Screen { + let color = &app.session.color; + let display = display.or(color.main_display); + let Ok(d) = color.canvas_display_for(doc, display) else { return Screen::default() }; + let proof = color.proof_ref(doc.id).is_some_and(|p| p.enabled); + let transform = if proof || d.transform.is_some() { color.display_transform_for(doc, display).ok() } else { None }; + Screen { encode_srgb: d.encode_srgb, transform } + } + + /// A key that changes with [`Screen::of`]. + pub fn key(app: &PhotocraftApp, doc: &Document, display: Option) -> u64 { + let color = &app.session.color; + color.display_signature_for(doc, display.or(color.main_display)) + } + + /// Opaque composite pixels `px` as monitor values. + fn apply(&self, px: &mut [[f32; 4]]) { + if self.encode_srgb { + for p in px.iter_mut() { + for v in &mut p[..3] { + *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0)); + } + } + } + if let Some(t) = &self.transform { + t.apply(px.as_flattened_mut(), 4); + } + } +} + /// Composite RGBA (over white, like Photoshop's channel views) at `factor` stride. /// Render in bands instead of allocating a full-resolution float composite to sample a preview. fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { + let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); + // A downsampled document (#10249): the whole canvas at `factor` from a proxy picks the same + // pixels (`downsample` keeps every factor-th one) for ~1/factor² of the compositing. + if factor > 1 && r == doc.bounds() && proxy_matches(doc) { + // Only Multichannel composites read the extra channels; the selection and Quick Mask + // never show in a composite. Left out, they cost nothing to shrink. + let mut layers_only = doc.clone(); + if doc.mode != ColorMode::Multichannel { + layers_only.channels.clear(); + } + layers_only.selection = None; + layers_only.quick_mask = None; + let p = photocraft_compose::proxy::proxy_document(&layers_only, factor); + let mut out = Vec::with_capacity((w * h) as usize); + let _ = photocraft_compose::render_bands(&p, Rect::new(0, 0, w as i32, h as i32), 0, |band| -> Result<(), ()> { + out.extend(band.px.iter().map(|p| { + let a = p[3].clamp(0.0, 1.0); + [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0] + })); + Ok(()) + }); + if out.len() == (w * h) as usize { + return out; + } + } + composite_sampled(doc, r, factor) +} + +/// [`composite`] sampled from the full-resolution composite, rendered in bands. +fn composite_sampled(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let mut out = Vec::with_capacity((w * h) as usize); let f = factor as i32; @@ -110,11 +191,86 @@ fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { out } +/// The samples of `r` (see [`composite`]): `cached` when it matches, else rendered, and kept in +/// `computed` for the cache when that is not too big. +fn composite_px<'a>(doc: &Document, r: Rect, factor: u32, cached: Option<&'a [[f32; 4]]>, computed: &mut Option>) -> Cow<'a, [[f32; 4]]> { + let n = r.width().div_ceil(factor) as usize * r.height().div_ceil(factor) as usize; + if let Some(px) = cached.filter(|px| px.len() == n) { + return Cow::Borrowed(px); + } + let px = composite(doc, r, factor); + if n <= MAX_COMPOSITE_CACHE_PIXELS { + *computed = Some(px.clone()); + } + Cow::Owned(px) +} + +/// The samples of `r` (see [`composite`]) at the grid indices `inked` (ascending), in that order. +/// A few inked pixels of a big document render just their bounding box; when that costs no less +/// than the whole composite (a proxy renders it at about 1/factor² of the pixels), the whole one +/// is used, from `cached` or rendered and kept in `computed` for the cache. +fn composite_at(doc: &Document, r: Rect, factor: u32, inked: &[usize], cached: Option<&[[f32; 4]]>, computed: &mut Option>) -> Vec<[f32; 4]> { + let w = r.width().div_ceil(factor) as usize; + let n = w * r.height().div_ceil(factor) as usize; + let pick = |px: &[[f32; 4]]| -> Vec<[f32; 4]> { inked.iter().filter_map(|&k| px.get(k).copied()).collect() }; + if let Some(px) = cached.filter(|px| px.len() == n) { + return pick(px); + } + let (Some(&first), Some(&last)) = (inked.first(), inked.last()) else { return Vec::new() }; + if w == 0 { + return Vec::new(); + } + // The inked samples' bounding box, in samples, then in document pixels (on the same grid). + let (bx0, bx1) = inked.iter().fold((usize::MAX, 0), |(lo, hi), &k| (lo.min(k % w), hi.max(k % w + 1))); + let (by0, by1) = (first / w, last / w + 1); + let f = factor as usize; + let at = |o: i32, s: usize| o.saturating_add((s * f) as i32); + let area = Rect::new(at(r.x0, bx0), at(r.y0, by0), at(r.x0, bx1 - 1).saturating_add(1).min(r.x1), at(r.y0, by1 - 1).saturating_add(1).min(r.y1)); + let proxy = factor > 1 && r == doc.bounds() && proxy_matches(doc); + let whole_cost = r.size().area() / if proxy { u64::from(factor) * u64::from(factor) } else { 1 }; + if area.size().area() < whole_cost { + let (px, bw) = (composite_sampled(doc, area, factor), bx1 - bx0); + return inked.iter().filter_map(|&k| px.get((k / w - by0) * bw + k % w - bx0).copied()).collect(); + } + pick(&composite_px(doc, r, factor, None, computed)) +} + +/// Whether `v` prints spot inks of `doc` over its composite, through the [`Screen`]. +fn prints_inks(doc: &Document, v: &ChannelView) -> bool { + let colors = color_count(doc); + v.visible_colors(colors) == colors && (0..doc.channels.len()).any(|i| v.alpha_shown(i) && photocraft_compose::multichannel::is_overprint_spot(doc, i)) +} + +/// Whether a proxy composite picks the full composite's pixels: no layer effects or vector masks +/// ([`proxy_faithful`](photocraft_compose::proxy::proxy_faithful)), no feathered masks, whose +/// radius is in document pixels, and no pattern fills, whose tiles keep their size on a proxy +/// unless a cache of the same fill is shrunk with it. +fn proxy_matches(doc: &Document) -> bool { + use photocraft_doc::{Fill, LayerContent}; + // A stack, not recursion: groups can nest deeply. Hidden layers (and what they hold) are not + // composited, so they don't count. + let mut stack: Vec<&photocraft_doc::Layer> = doc.layers.iter().filter(|l| l.visible).collect(); + while let Some(l) = stack.pop() { + if photocraft_compose::masks::has_feather(l) { + return false; + } + if let LayerContent::Fill(fill @ Fill::Pattern { .. }) = &l.content + && l.fill_cache.as_ref().is_none_or(|c| c.fill != *fill) + { + return false; + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().filter(|c| c.visible)); + } + } + photocraft_compose::proxy::proxy_faithful(doc) +} + /// The channel view of `r` (document pixels, sampled every `factor` px) as premultiplied pixels, /// or `None` when the plain composite is all there is to show. pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, look: Look, active: Option) -> Option> { let mut unused = None; - render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused }) + render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused, screen: &Screen::default() }) } fn render_with_source( @@ -161,16 +317,41 @@ fn render_with_source( let vis = v.visible_colors(colors); let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let n = (w * h) as usize; - let mut overlays: Vec<&AlphaChannel> = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).map(|i| &doc.channels[i]).collect(); + let mut overlays: Vec = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).collect(); let gray = |s: &Surface| sample(s, r, factor).into_iter().map(|g| [g, g, g, 1.0]).collect(); + // The sampled composite over white: the cached one when it matches, else rendered here (and + // handed back to the cache). + let mut computed = None; + // Visible spot channels of an RGB or CMYK document print over the picture as inks, in panel + // order, before the alpha channel and Quick Mask tints. + let spots: Vec<(photocraft_compose::multichannel::Ink, Vec)> = if vis > 0 { + let spot_ids: Vec = overlays.iter().copied().filter(|&i| photocraft_compose::multichannel::is_overprint_spot(doc, i)).collect(); + overlays.retain(|i| !spot_ids.contains(i)); + // The ink colours are converted once per render, and only when there are inks to print. + let space = (!spot_ids.is_empty()).then(|| photocraft_compose::multichannel::InkSpace::of(doc)); + space.iter().flat_map(|space| spot_ids.iter().filter_map(|&i| doc.channels.get(i)).map(|ch| (space.ink(ch), sample(&ch.surface, r, factor)))).collect() + } else { + Vec::new() + }; + // Over the whole composite, spot inks print (#10249) only where they have ink. + let inked: Vec = if vis == colors && !spots.is_empty() { + (0..n).filter(|&k| spots.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0))).collect() + } else { + Vec::new() + }; + // No ink, and no alpha channel, Quick Mask or layer mask shown over the composite: nothing to + // draw, so no all-transparent texture either. + if vis == colors && inked.is_empty() && overlays.is_empty() && (doc.quick_mask.is_none() || v.quick_mask_hidden) && shown_mask.is_none() { + return None; + } // Premultiplied RGBA accumulator. With no colour channel left, the channel being edited - // (alpha, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. + // (alpha, spot, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. let mut solo_quick = false; let mut solo_mask = false; let mut out: Vec<[f32; 4]> = if vis == 0 { if !overlays.is_empty() { let first = overlays.remove(0); - gray(&first.surface) + gray(&doc.channels.get(first)?.surface) } else if let Some(q) = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden) { solo_quick = true; gray(&q.surface) @@ -186,12 +367,10 @@ fn render_with_source( let fmt = doc.pixel_format(); let mode = fmt.mode; let shown: Vec = (0..colors).filter(|k| v.color_visible(*k)).collect(); - let computed = if source.cached.is_some_and(|px| px.len() == n) { None } else { Some(composite(doc, r, factor)) }; - let pixels = source.cached.filter(|px| px.len() == n).or(computed.as_deref())?; - let converted = pixels + let pixels = composite_px(doc, r, factor, source.cached, &mut computed); + pixels .iter() - .copied() - .map(|rgba| { + .map(|&rgba| { let mut native = from_rgba(&fmt, rgba); if shown.len() == 1 && !in_color { let x = native[shown[0]]; @@ -203,21 +382,40 @@ fn render_with_source( if !v.color_visible(k) { // Hidden channels contribute nothing (Lab: neutral a/b, and a neutral // Lightness under Show Channels in Color, so an isolated a/b channel - // tints over mid gray instead of black, as Photoshop does). + // tints over mid gray instead of black, as the reference editor does). *x = if mode == ColorMode::Lab && (k > 0 || in_color) { 0.5 } else { 0.0 }; } } let c = to_rgba(&fmt, &native); [c[0], c[1], c[2], 1.0] }) - .collect(); - if n <= MAX_COMPOSITE_CACHE_PIXELS { - *source.fresh = computed; - } - converted + .collect() } else { vec![[0.0; 4]; n] }; + if !inked.is_empty() { + // The inked pixels become the opaque printed picture, the rest stay transparent so the + // normal canvas (display LUT, proofing, full resolution) shows. Only the inked area of the + // composite is rendered. + let mut printed = composite_at(doc, r, factor, &inked, source.cached, &mut computed); + if printed.len() == inked.len() { + for (ink, vals) in &spots { + ink.apply(&mut printed, inked.iter().map(|&k| vals.get(k).copied().unwrap_or(0.0))); + } + source.screen.apply(&mut printed); + for (&k, p) in inked.iter().zip(printed) { + if let Some(o) = out.get_mut(k) { + *o = p; + } + } + } + } else if vis < colors { + for (ink, vals) in &spots { + ink.apply(&mut out, vals.iter().copied()); + } + } + *source.fresh = computed; + let overlays: Vec<&AlphaChannel> = overlays.into_iter().filter_map(|i| doc.channels.get(i)).collect(); // Rubylith: `color` at `opacity` × coverage over the accumulated pixels. let mut tint = |values: Vec, color: photocraft_color::Color, opacity: f32, coverage: &dyn Fn(f32) -> f32| { let c = color.to_rgb(); @@ -231,8 +429,9 @@ fn render_with_source( }; let quick = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden && !solo_quick); for ch in overlays.into_iter().chain(quick) { + // Spot channels reach here only in Multichannel documents, whose composite already prints + // them: they show more solid as solidity rises. let (color, opacity) = match ch.spot { - // Spot inks show more solid as solidity rises. Some((ink, solidity)) => (ink, 0.5 + 0.5 * solidity), None => (ch.color, ch.opacity), }; @@ -284,8 +483,9 @@ fn view_key(v: &ChannelView, look: Look) -> u64 { s.bytes().fold(look.in_color as u64 + 1, |h, b| h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64)) } -/// Make the channel-view texture of document `idx` current; `None` = nothing to draw. -pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { +/// Make the channel-view texture of document `idx`, shown on `display` (`None`: the main +/// window's), current; `None` = nothing to draw. +pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option) -> Option { let st = app.session.documents().get(idx)?; let view = st.channel_view.clone(); let active = st.active_layer; @@ -306,7 +506,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // not just the panel thumbnails (#2385). The view key carries the flag and the Layer Mask // Display Options, so changing them rebuilds the texture. let look = Look::of(&app.session); - let key = view_key(&view, look); + // Printed spot inks go through the display transform, so its key is part of the view's. + let inks = prints_inks(&doc, &view); + let screen_key = if inks { Screen::key(app, &doc, display) } else { 0 }; + let key = view_key(&view, look) ^ screen_key.rotate_left(17); // 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); @@ -326,14 +529,17 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio && c.revision == revision && c.preview_key == preview_key { - return Some(c.tex.id()); + return c.tex.as_ref().map(|t| t.id()); } + // The display transform itself is looked up only when the view is redrawn. + let screen = if inks { Screen::of(app, &doc, display) } else { Screen::default() }; let damage = app.channel_views.get(&id).and_then(|c| crate::canvas::damage_since(app, idx, (c.revision, c.preview_key), (revision, preview_key), 0, last_damage)); if let Some(c) = app.channel_views.get_mut(&id) && c.key == key && c.factor == factor && c.valid + && let Some(tex) = c.tex.as_mut() && let Some(d) = damage { // Re-render just the damage, snapped to the sampling grid so a downsampled texture @@ -341,26 +547,48 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let f = factor as i32; let d = d.intersect(&doc.bounds()); let r = Rect::new(d.x0.div_euclid(f) * f, d.y0.div_euclid(f) * f, d.x1, d.y1).intersect(&doc.bounds()); - if !r.is_empty() - && let Some(px) = render(&doc, &view, r, factor, look, active) - { - let img = egui::ColorImage::new([r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize], px); - c.tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], img, TextureOptions::LINEAR); + if !r.is_empty() { + let size = [r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize]; + // Nothing to draw there (the ink erased) still clears what the texture showed. + let px = render_with_source(&doc, &view, r, factor, look, active, CompositeSource { cached: None, fresh: &mut None, screen: &screen }) + .unwrap_or_else(|| vec![Color32::TRANSPARENT; size[0] * size[1]]); + tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], egui::ColorImage::new(size, px), TextureOptions::LINEAR); } + let tex_id = tex.id(); c.revision = revision; c.preview_key = preview_key; - return Some(c.tex.id()); + return Some(tex_id); } let b = doc.bounds(); let cached = app.channel_views.get(&id).and_then(|c| c.source.as_ref()).map(|s| s.pixels.as_slice()); let mut fresh = None; - let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh })?; + let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh, screen: &screen }); + let Some(px) = px else { + // Nothing to draw (visible spot channels without ink): remembered, so the next frames + // don't look again, and the texture is dropped. + match app.channel_views.get_mut(&id) { + Some(c) => { + c.tex = None; + c.revision = revision; + c.preview_key = preview_key; + c.key = key; + c.factor = factor; + c.valid = true; + } + None => { + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: None, source: None }); + } + } + return None; + }; let new_source = fresh.is_some(); let size = [b.width().div_ceil(factor) as usize, b.height().div_ceil(factor) as usize]; let img = egui::ColorImage::new(size, px); match app.channel_views.get_mut(&id) { - Some(c) if c.tex.size() == size => { - c.tex.set(img, TextureOptions::LINEAR); + Some(c) if c.tex.as_ref().is_some_and(|t| t.size() == size) => { + if let Some(t) = c.tex.as_mut() { + t.set(img, TextureOptions::LINEAR); + } c.revision = revision; c.preview_key = preview_key; c.key = key; @@ -372,8 +600,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - 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 }); + // A composite still current (checked above) is kept when none was rendered. + let kept = app.channel_views.remove(&id).and_then(|c| c.source); + let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }).or(kept); + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: Some(tex), source }); } } if new_source { @@ -383,7 +613,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } } } - app.channel_views.get(&id).map(|c| c.tex.id()) + app.channel_views.get(&id).and_then(|c| c.tex.as_ref()).map(|t| t.id()) } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/channel_view/tests.rs b/crates/ui-egui/src/canvas/channel_view/tests.rs index d8581ebbbb..fa5d84e186 100644 --- a/crates/ui-egui/src/canvas/channel_view/tests.rs +++ b/crates/ui-egui/src/canvas/channel_view/tests.rs @@ -41,7 +41,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { app.sync_views(); let ctx = egui::Context::default(); app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let id = app.session.active().unwrap().doc.id.0; let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); let original_sample = first.pixels[0]; @@ -49,19 +49,19 @@ fn channel_eyes_reuse_composite_until_pixels_change() { 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()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); 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()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); // A partial damage upload discards the source; a full redraw may replace it. // In either case, cached samples must never refer to the pre-edit pixels. if let Some(source) = app.channel_views.get(&id).unwrap().source.as_ref() { assert_ne!(source.pixels[0], original_sample, "a full redraw replaces stale pixels"); } app.run("channel.setVisible", json!({"channel": "red", "visible": true})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let refreshed = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); let st = app.session.active().unwrap(); assert_eq!(refreshed.revision, st.revision); @@ -75,7 +75,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { 1, Look::default(), st.active_layer, - CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused }, + CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused, screen: &Screen::default() }, ) .unwrap(); assert_eq!(actual, expected, "reused pixels must preserve channel colours"); @@ -285,15 +285,15 @@ fn ensure_honours_show_channels_in_color() { app.sync_views(); app.run("channel.target", json!({"channel": "green"})).unwrap(); let ctx = egui::Context::default(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_off = app.channel_views.values().next().unwrap().key; app.session.execute("prefs.set", json!({"values": {"interface.showChannelsInColor": true}})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_on = app.channel_views.values().next().unwrap().key; assert_ne!(key_off, key_on, "the preference changes the cached view key"); // So do the Layer Mask Display Options. app.session.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 70})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); assert_ne!(app.channel_views.values().next().unwrap().key, key_on, "the overlay options change the cached view key"); } @@ -349,3 +349,426 @@ fn rgb_off_shows_alpha_quick_mask_or_the_layer_mask() { let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::BLACK, "the hidden mask reads black, not the red layer"); } + +/// A white-backed `mode`/`depth` document, 3×1, with a 0.8/0.4/0.2 picture and two overlapping +/// spot inks (`a` covers 1, ½, 0 of the pixels; `b` the reverse). +fn spot_doc(mode: ColorMode, depth: photocraft_color::SampleType, sa: f32, sb: f32) -> Document { + use photocraft_color::{Color, PixelFormat}; + let mut doc = Document::with_background("spots", photocraft_geom::Size::new(3, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + for (name, rgb, s, vals) in [("a", [0.0, 0.6, 0.65], sa, [1.0, 0.5, 0.0]), ("b", [0.9, 0.2, 0.5], sb, [0.0, 0.5, 1.0])] { + let mut sf = Surface::new(fmt); + sf.write_region(Rect::new(0, 0, 3, 1), &vals); + doc.channels.push(AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), s)), ..AlphaChannel::new(name, sf) }); + } + doc +} + +/// Visible spot channels of RGB and CMYK documents print over the composite as inks (#10249), +/// the same as the compositor's overprint, in panel order; not as a tint. +#[test] +fn spot_eyes_overprint_the_composite() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [photocraft_color::SampleType::U8, photocraft_color::SampleType::U16] { + for (sa, sb) in [(0.0, 0.0), (0.5, 1.0), (1.0, 0.5)] { + for swapped in [false, true] { + let mut doc = spot_doc(mode, depth, sa, sb); + if swapped { + doc.channels.reverse(); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |_| true); + for (x, (got, w)) in px.iter().zip(&want.px).enumerate() { + assert_eq!(got.a(), 255, "{mode:?} {depth:?}: opaque"); + let w = [q(w[0]), q(w[1]), q(w[2])]; + assert_eq!([got.r(), got.g(), got.b()], w, "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}"); + } + // Only the visible ink prints; where it has none the canvas shows through. + v.set_alpha_visible(1, false); + let one = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |i| i == 0); + let (solid, bare) = if swapped { (2, 0) } else { (0, 2) }; + let w = want.px[solid]; + assert_eq!([one[solid].r(), one[solid].g(), one[solid].b()], [q(w[0]), q(w[1]), q(w[2])]); + assert_eq!(one[bare], Color32::TRANSPARENT); + } + } + } + } +} + +/// Only inked pixels replace the canvas: where every visible spot is 0 the overlay stays +/// transparent, so the normal (colour-managed, full-resolution) canvas shows there, and an empty +/// spot channel draws nothing and renders no composite at all. +#[test] +fn uninked_pixels_leave_the_canvas_alone() { + for factor in [1, 2] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.size = photocraft_geom::Size::new(4, 2); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(0, 0, &[1.0]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), factor, Look::default(), None).unwrap(); + assert_eq!(px[0], Color32::from_rgb(0, q(0.6), q(0.65)), "/{factor}: solid teal where the ink is"); + assert!(px[1..].iter().all(|p| *p == Color32::TRANSPARENT), "/{factor}: {px:?}"); + // Only the empty spot shown: nothing to print, and no composite rendered for it. + v.set_alpha_visible(0, false); + let mut fresh = None; + let px = render_with_source( + &doc, + &v, + doc.bounds(), + factor, + Look::default(), + None, + CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }, + ); + assert!(px.is_none(), "/{factor}: an empty spot draws no texture"); + assert!(fresh.is_none(), "/{factor}: an empty spot needs no composite"); + } +} + +/// A few inked pixels composite only their bounding box, not the whole document, and print the +/// same as over the whole composite; ink everywhere renders the whole one, kept for the cache. +#[test] +fn sparse_inks_composite_only_their_bounding_box() { + use photocraft_doc::{Layer, LayerContent}; + for factor in [1, 2, 3] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(60, 40); + let mut ramp = Surface::new(doc.pixel_format()); + for y in 0..40 { + for x in 0..60 { + ramp.write_pixel(x, y, &[x as f32 / 59.0, y as f32 / 39.0, 0.5, 1.0]); + } + } + doc.layers.push(Layer::new("ramp", LayerContent::Raster(ramp))); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(12, 0, &[1.0]); + sparse.write_pixel(18, 6, &[0.5]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let b = doc.bounds(); + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + let got = render_with_source(&doc, &v, b, factor, Look::default(), None, source).unwrap(); + assert!(fresh.is_none(), "/{factor}: two inked pixels render no whole composite"); + let whole = composite_sampled(&doc, b, factor); + let ink = photocraft_compose::multichannel::Ink::of(&doc, &doc.channels[0]); + let w = 60usize.div_ceil(factor as usize); + let f = factor as usize; + let inked = [(12 / f, 0), (18 / f, 6 / f)]; + for (k, px) in got.iter().enumerate() { + let Some(&(x, y)) = inked.iter().find(|&&(x, y)| y * w + x == k) else { + assert_eq!(*px, Color32::TRANSPARENT, "/{factor} px {k}"); + continue; + }; + let mut want = [whole[k]]; + ink.apply(&mut want, [doc.channels[0].surface.pixel((x * f) as i32, (y * f) as i32)[0]]); + let want = Color32::from_rgb(q(want[0][0]), q(want[0][1]), q(want[0][2])); + assert_eq!(*px, want, "/{factor} ({x}, {y})"); + } + // Ink everywhere: the bounding box is the whole document, rendered once for the cache. + let mut solid = Surface::new(fmt); + solid.fill_rect(b, &[1.0]); + doc.channels[1].surface = solid; + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + assert!(render_with_source(&doc, &v, b, factor, Look::default(), None, source).is_some()); + assert!(fresh.is_some(), "/{factor}: ink everywhere renders the whole composite"); + } +} + +/// A visible spot channel without ink draws no texture over the canvas, and remembers that; a +/// spot with ink shown later draws one. +#[test] +fn uninked_spot_draws_no_texture() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + // No selection: an empty spot channel, shown over the composite. + app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let st = app.session.active().unwrap(); + assert!(prints_inks(&st.doc, &st.channel_view), "the composite and the spot are both shown"); + assert!(ensure(&mut app, &ctx, 0, None).is_none(), "no ink: nothing drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_none() && c.valid), "and remembered"); + assert!(ensure(&mut app, &ctx, 0, None).is_none()); + // A second spot made from a selection has ink: the texture shows it. + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("channel.newSpot", json!({"color": "#ff00ff", "solidity": 0})).unwrap(); + assert!(ensure(&mut app, &ctx, 0, None).is_some(), "ink: drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_some())); +} + +/// Printed inks go through the canvas display transform (monitor profile, Proof Colors), as the +/// canvas around them does. +#[test] +fn printed_inks_follow_the_display_transform() { + use photocraft_cms::{Builtin, TransformOptions}; + let doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let plain = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let t = photocraft_cms::transform::cached(Builtin::Srgb.profile(), Builtin::DisplayP3.profile(), TransformOptions::default()).unwrap(); + let screen = Screen { encode_srgb: false, transform: Some(t.clone()) }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &screen }).unwrap(); + // x 0: solid teal; x 2: no ink of channel 0, so transparent either way. + let mut teal = [0.0f32, 0.6, 0.65, 1.0]; + t.apply(&mut teal, 4); + assert_ne!(plain[0], shown[0]); + assert_eq!(shown[0], Color32::from_rgb(q(teal[0]), q(teal[1]), q(teal[2]))); + assert_eq!((plain[2], shown[2]), (Color32::TRANSPARENT, Color32::TRANSPARENT)); +} + +/// A linear (32-bit) document's composite is sRGB-encoded on its way to the screen. Its inks are +/// sRGB colours converted into the linear profile, so a solid ink shows its stored colour, not a +/// brightened one. +#[test] +fn printed_inks_keep_their_colour_in_a_linear_document() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::F32, 1.0, 1.0); + doc.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let encoded = Screen { encode_srgb: true, transform: None }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &encoded }).unwrap(); + let (got, want) = ([shown[0].r(), shown[0].g(), shown[0].b()], [0, q(0.6), q(0.65)]); + assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "{got:?} vs the stored {want:?}"); +} + +/// Alpha channels and Quick Mask still tint, over the printed inks. +#[test] +fn alpha_tint_draws_over_the_inks() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut sel = Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, photocraft_color::SampleType::U8, false)); + sel.write_region(Rect::new(0, 0, 3, 1), &[0.0, 0.0, 0.0]); + doc.channels.push(AlphaChannel::new("Alpha 1", sel)); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(2, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + // Solid opaque teal at x 0, then the default red rubylith at 50 % over it. + let teal = [0.0f32, 0.6, 0.65]; + let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); + let want: Vec = (0..3).map(|j| q(red[j] * 0.5 + teal[j] * 0.5)).collect(); + assert_eq!(vec![px[0].r(), px[0].g(), px[0].b()], want); +} + +/// Editing a spot (colour, solidity, visibility) or the picture under it refreshes the canvas texture. +#[test] +fn spot_edits_refresh_the_overlay() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let pixel = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).map(|px| px[0]) + }; + assert!(ensure(&mut app, &ctx, 0, None).is_some()); + assert_eq!(pixel(&app), Some(Color32::from_rgb(0, 255, 255)), "solid transparent cyan over white"); + let edits = [ + ("channel.options", json!({"channel": spot, "color": "#ff00ff"})), + // A gray picture first: a solid ink over white looks the same at any solidity. + ("edit.fill", json!({"color": "#808080"})), + ("channel.options", json!({"channel": spot, "solidity": 100})), + ("channel.setVisible", json!({"channel": spot, "visible": false})), + ]; + let mut last = pixel(&app); + for (cmd, params) in edits { + app.run(cmd, params.clone()).unwrap(); + let shown = ensure(&mut app, &ctx, 0, None); + let now = pixel(&app); + assert_ne!(now, last, "{cmd} {params}"); + let revision = app.session.active().unwrap().revision; + match shown { + Some(_) => assert_eq!(app.channel_views.get(&id).unwrap().revision, revision, "{cmd} {params}"), + None => assert!(now.is_none(), "a hidden spot leaves the plain composite"), + } + last = now; + } +} + +/// The first draw of a big document composites a proxy, not the full canvas (#10249): it must +/// pick the same pixels the full-resolution composite gives, in RGB and CMYK at 8 and 16 bit, +/// with the inks printed over it the same way. +#[test] +fn proxy_composite_matches_the_full_resolution_one() { + use photocraft_color::{BlendMode, SampleType}; + use photocraft_doc::{Layer, LayerContent, LayerMask}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + for factor in [2, 3, 4] { + let mut doc = spot_doc(mode, depth, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(37, 23); + let mut s = Surface::new(doc.pixel_format()); + for y in 0..23 { + for x in 0..37 { + let (fx, fy) = (x as f32 / 36.0, y as f32 / 22.0); + s.write_pixel(x, y, &[fx, fy, (fx * fy * 7.0).fract(), 0.3 + 0.7 * fy]); + } + } + let mut layer = Layer::new("pattern", LayerContent::Raster(s)); + layer.blend = BlendMode::Multiply; + layer.opacity = 0.8; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(5, 3, 20, 15), &[0.25]); + layer.mask = Some(mask); + doc.layers.push(layer); + for ch in &mut doc.channels { + let mut sf = Surface::new(ch.surface.format()); + for y in 0..23 { + for x in 0..37 { + sf.write_pixel(x, y, &[((x * 3 + y * 5) % 11) as f32 / 10.0]); + } + } + ch.surface = sf; + } + assert!(proxy_matches(&doc)); + let b = doc.bounds(); + let (proxy, full) = (composite(&doc, b, factor), composite_sampled(&doc, b, factor)); + assert_eq!(proxy.len(), full.len()); + for (i, (p, f)) in proxy.iter().zip(&full).enumerate() { + assert!(p.iter().zip(f).all(|(p, f)| (p - f).abs() < 1e-4), "{mode:?} {depth:?} /{factor} px {i}: {p:?} != {f:?}"); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let got = render(&doc, &v, b, factor, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { + rect: Rect::new(0, 0, 37usize.div_ceil(factor as usize) as i32, 23usize.div_ceil(factor as usize) as i32), + px: full, + }; + let mut small = doc.clone(); + small.channels.iter_mut().for_each(|c| c.surface = photocraft_compose::proxy::downsample(&c.surface, factor)); + photocraft_compose::multichannel::overprint(&mut want, &small, |_| true); + let sw = want.rect.width(); + let inked = |i: usize| { + let (x, y) = ((i as u32 % sw) as i32, (i as u32 / sw) as i32); + small.channels.iter().any(|c| c.surface.pixel(x, y).first().is_some_and(|v| *v > 0.0)) + }; + for (i, (g, w)) in got.iter().zip(&want.px).enumerate() { + let w = if inked(i) { Color32::from_rgba_premultiplied(q(w[0]), q(w[1]), q(w[2]), 255) } else { Color32::TRANSPARENT }; + assert!(g.to_array().iter().zip(w.to_array()).all(|(a, b)| a.abs_diff(b) <= 1), "{mode:?} {depth:?} /{factor} px {i}: {g:?} != {w:?}"); + } + } + } + } +} + +/// Feathered masks blur by document pixels, which a proxy would not scale: those documents keep +/// the full-resolution composite. +#[test] +fn feathered_masks_keep_the_full_resolution_composite() { + use photocraft_doc::{Layer, LayerContent, LayerMask}; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + assert!(proxy_matches(&doc)); + let mut mask = LayerMask::reveal_all(); + mask.feather = 4.0; + let mut inner = Layer::new("inner", LayerContent::Raster(Surface::new(doc.pixel_format()))); + inner.mask = Some(mask); + doc.layers.push(Layer::group("group", vec![inner])); + assert!(!proxy_matches(&doc)); + // Hidden layers are not composited: a hidden group (or a hidden layer in it) doesn't count. + let last = doc.layers.len() - 1; + doc.layers[last].visible = false; + assert!(proxy_matches(&doc)); + doc.layers[last].visible = true; + if let LayerContent::Group(g) = &mut doc.layers[last].content { + g.children[0].visible = false; + } + assert!(proxy_matches(&doc)); +} + +/// A pattern fill's tiles keep their size on a proxy, so it would sample other pattern pixels: +/// such documents keep the full-resolution composite unless the fill's own cache is shrunk with +/// the proxy. +#[test] +fn uncached_pattern_fills_keep_the_full_resolution_composite() { + use photocraft_doc::{Fill, FillCache, Layer, LayerContent}; + let pattern = Fill::Pattern { name: "p".into(), scale: 1.0, id: "p".into(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.layers.push(Layer::group("group", vec![Layer::new("pattern", LayerContent::Fill(pattern.clone()))])); + assert!(!proxy_matches(&doc)); + let mut stale = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + stale.fill_cache = Some(FillCache { fill: Fill::Solid(photocraft_color::Color::WHITE), surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(stale); + assert!(!proxy_matches(&doc)); + let mut cached = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + cached.fill_cache = Some(FillCache { fill: pattern, surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(cached); + assert!(proxy_matches(&doc)); +} + +/// Solo spot channel view is grayscale and updates with edits on an RGB or CMYK document with +/// no layer mask. +#[test] +fn solo_spot_is_grayscale_and_edits_without_layer_mask() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + let mode_str = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" }; + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode_str})).unwrap(); + // Plain document has a background layer and no layer mask. + assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.mask.is_none())); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + // Target the spot channel alone (Channels panel row click). + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer) + .expect("solo spot channel must render without a layer mask"); + // Selected half (x 0..4) has ink (1.0 -> white in grayscale tone), unselected half is black (0.0). + assert_eq!(px[0], Color32::WHITE, "{mode:?}: inked half is white tone"); + assert_eq!(px[7], Color32::BLACK, "{mode:?}: uninked half is black tone"); + + // Editing the targeted spot channel updates the solo preview. + app.run("select.rect", json!({"x": 4, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + let st = app.session.active().unwrap(); + let px2 = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("edited spot channel must render"); + assert_eq!(px2[7], Color32::WHITE, "{mode:?}: newly filled area is now white"); + } +} + +#[test] +fn solo_spot_wins_over_an_unrelated_layer_mask() { + for mode in ["rgb", "cmyk"] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("solo spot renders"); + // A revealed layer mask is all white; the spot has ink on the left half only. + assert_eq!(px[0], Color32::WHITE, "{mode}: inked half"); + assert_eq!(px[7], Color32::BLACK, "{mode}: uninked half shows the spot, not the mask"); + } +} diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e93b1840dc..dee8017444 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -10,7 +10,7 @@ pub(super) fn visible_crosshair(icon: egui::CursorIcon, painter: &egui::Painter, if !draw || icon != egui::CursorIcon::Crosshair { return icon; } - crate::tool_cursor::crosshair(painter, p, 8.0, 2.0) + crate::tool_cursor::standard_crosshair(painter, p) } /// The Pencil's cursor at `doc` (document pixels): the whole-pixel square its dab fills @@ -150,12 +150,39 @@ pub(crate) fn pipette_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { egui::CursorIcon::None } -/// The Paint Bucket's cursor: a bucket whose pouring corner is the filled pixel. Draws it at `p` -/// and returns the OS cursor to set (hidden). -pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { - // The left corner of the icon's bucket is at (2, 12) of its 24-unit box. - crate::icons::cursor(ctx, "paint-bucket", p, vec2(2.0, 12.0) / 24.0, 22.0); - egui::CursorIcon::None +/// The Paint Bucket artwork and target share one cached cursor image. Report rasterization +/// failures once per distinct message, with the same concise error in the dialog and status bar. +pub(crate) fn paint_bucket_cursor(app: &mut PhotocraftApp, painter: &egui::Painter, p: Pos2) -> egui::CursorIcon { + let ctx = painter.ctx(); + let id = egui::Id::unique("photocraft-bucket-cursor-error"); + match crate::tool_cursor::bucket(painter, p) { + Ok(icon) => { + // The failure belonged to another scale: its status goes, and a new one reports again. + if let Some(error) = ctx.data(|data| data.get_temp::(id)) { + ctx.data_mut(|data| data.remove::(id)); + if app.ui.status == error { + app.ui.status.clear(); + app.ui.status_error = false; + } + } + icon + } + Err(error) => { + if ctx.data(|data| data.get_temp::(id)).as_ref() != Some(&error) { + if !app.ui.dialogs.iter().any(|dialog| { + dialog.kind == crate::state::DialogKind::Error && dialog.fields.get("message").and_then(serde_json::Value::as_str) == Some(&error) + }) { + let mut fields = serde_json::Map::new(); + fields.insert("message".into(), json!(error)); + app.ui.open_dialog(crate::state::DialogKind::Error, fields); + } + ctx.data_mut(|data| data.insert_temp(id, error.clone())); + } + app.ui.status = error; + app.ui.status_error = true; + egui::CursorIcon::Default + } + } } /// Decided when the press starts, so ⌥ pressed or released mid-stroke never switches between diff --git a/crates/ui-egui/src/canvas/gpu_canvas.rs b/crates/ui-egui/src/canvas/gpu_canvas.rs index af3d574980..0311f56934 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas.rs @@ -39,6 +39,7 @@ pub use photocraft_gpu::{DeviceHealth, Fault}; mod budget; mod callback; +mod compositor_build; mod doc_textures; mod pages; mod perf; @@ -53,8 +54,8 @@ use budget::detect_video_memory; #[cfg(all(test, target_os = "linux"))] use budget::nvidia_video_memory; pub use budget::{ - DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes, memory_budget, - physical_memory, video_memory, vram_share, + COMPOSITOR_BUILDING, DEFER_PX, MIN_GPU_BUDGET, OVER_BUDGET, OVER_CANVAS_BUDGET, REFRESH_DEFERRED, REFRESH_OVER_BUDGET, canvas_budget, canvas_texture_bytes, + memory_budget, physical_memory, video_memory, vram_share, }; #[cfg(test)] use budget::{NVIDIA_VENDOR, bounded_output, drm_video_memory, parse_nvidia_smi}; @@ -171,6 +172,8 @@ pub struct GpuCanvas { /// Camera Raw's preview pass, built the first time the dialog asks (#6419). camera_raw: crate::camera_raw_gpu::Slot, camera_raw_started: std::sync::Arc, + compositor_build: compositor_build::Slot, + compositor_completion: compositor_build::Completion, } /// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on @@ -228,7 +231,61 @@ impl GpuCanvas { rs.target_format, vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20)) ); - Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget, camera_raw: Default::default(), camera_raw_started: Default::default() } + Self { + rs: rs.clone(), + tile, + high, + health, + vram, + canvas_budget: budget, + camera_raw: Default::default(), + camera_raw_started: Default::default(), + compositor_build: Default::default(), + compositor_completion: Default::default(), + } + } + + /// Start compositor compilation off the UI thread on native hardware. + pub fn start_compositor(&self, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if self.rs.adapter.get_info().device_type != wgpu::DeviceType::Cpu && std::env::var_os("PHOTOCRAFT_CPU_COMPOSE").is_none() { + compositor_build::start( + &self.rs.device, + photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter), + ctx, + &self.compositor_build, + &self.compositor_completion, + ); + } + #[cfg(target_arch = "wasm32")] + let _ = ctx; + } + + /// Key the memo to this device, application and shader compiler. + pub fn compositor_memo_key(&self, compiler: &str) -> crate::gpu_memo::MemoKey { + let info = self.rs.adapter.get_info(); + crate::gpu_memo::MemoKey { + app_version: env!("CARGO_PKG_VERSION").into(), + shader_fingerprint: photocraft_gpu::shader_fingerprint(), + adapter_name: info.name, + vendor: info.vendor, + device: info.device, + driver: info.driver, + driver_info: info.driver_info, + backend: format!("{:?}", info.backend), + shader_compiler: compiler.into(), + } + } + + /// Use a known deterministic failure without recompiling the same shaders. + pub fn remember_compositor_failure(&self, error: &str) { + *self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = + compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(format!("remembered from an earlier launch: {error}"))); + } + + /// Take the background build result once, independently of compositor adoption. + pub fn take_compositor_completion(&self) -> Option> { + self.compositor_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() } /// The device's health flag (shared with the wgpu compositor and the paint callback). @@ -402,6 +459,28 @@ impl GpuCanvas { } } + #[cfg(test)] + pub(crate) fn simulate_compositor_build(&self) { + *self.compositor_build.lock().unwrap() = compositor_build::BuildState::Building; + } + + #[cfg(test)] + pub(crate) fn simulate_compositor_completion(&self, success: bool) -> bool { + let (state, outcome) = if success { + let Ok(comp) = photocraft_gpu::Compositor::try_new_with_format(&self.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) + else { + return false; + }; + (compositor_build::BuildState::Ready(Box::new(comp)), Ok(())) + } else { + let message = "simulated build failure".to_string(); + (compositor_build::BuildState::Failed(photocraft_gpu::Unsupported(message.clone())), Err((message, false))) + }; + *self.compositor_build.lock().unwrap() = state; + *self.compositor_completion.lock().unwrap() = Some(outcome); + true + } + /// Why the compositor couldn't be built, once it couldn't (see [`GpuCanvas::composite`]). fn compositor_failed(&self) -> Option { let r = self.rs.renderer.read(); @@ -422,6 +501,9 @@ impl GpuCanvas { let (bytes, budget) = self.canvas_fit(doc).unwrap_or_default(); return CpuReason::CanvasTooLarge { bytes, budget }; } + if e.0 == COMPOSITOR_BUILDING { + return CpuReason::CompositorBuilding; + } if self.compositor_failed().is_some_and(|f| f == e.0) { return CpuReason::CompositorBuild(e.0); } @@ -623,6 +705,22 @@ impl GpuCanvas { if let Some(e) = &res.compositor_failed { return Err(e.clone()); } + { + let mut state = self.compositor_build.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *state, compositor_build::BuildState::Adopted) { + compositor_build::BuildState::Building => { + *state = compositor_build::BuildState::Building; + return Err(photocraft_gpu::Unsupported(COMPOSITOR_BUILDING.into())); + } + compositor_build::BuildState::Ready(comp) => res.compositor = Some(*comp), + compositor_build::BuildState::Failed(error) => { + res.compositor_failed = Some(error.clone()); + return Err(error); + } + compositor_build::BuildState::NotStarted => *state = compositor_build::BuildState::NotStarted, + compositor_build::BuildState::Adopted => (), + } + } let mut comp = match res.compositor.take() { Some(c) => c, None => match photocraft_gpu::Compositor::try_new_with_format(device, photocraft_gpu::Compositor::preferred_acc_format(&self.rs.adapter)) { @@ -809,8 +907,12 @@ impl GpuCanvas { out.gpu = stats.gpu_time; return out; } - Err(e) => out.fallback = Some(self.reason(doc, e)), + Err(e) => { + out.gpu_attempt_ms = now_ms() - t0; + out.fallback = Some(self.reason(doc, e)); + } } + let t0 = now_ms(); 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; diff --git a/crates/ui-egui/src/canvas/gpu_canvas/budget.rs b/crates/ui-egui/src/canvas/gpu_canvas/budget.rs index 242eebaaa9..772b6f828e 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/budget.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/budget.rs @@ -8,6 +8,9 @@ pub const MIN_GPU_BUDGET: u64 = 512 << 20; /// CPU compositor. pub const OVER_BUDGET: &str = photocraft_gpu::OVER_BUDGET; +/// Why a full refresh goes to the CPU compositor while the GPU one is still being built. +pub const COMPOSITOR_BUILDING: &str = "GPU compositor is building"; + /// GPU memory the wgpu compositor may hold for layer pages and effect maps. /// /// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within diff --git a/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs b/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs new file mode 100644 index 0000000000..5e280a05af --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/compositor_build.rs @@ -0,0 +1,76 @@ +//! Builds the compositor without blocking the UI's first document refresh. +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +pub(super) enum BuildState { + #[default] + NotStarted, + Building, + Ready(Box), + Failed(photocraft_gpu::Unsupported), + Adopted, +} + +pub(super) type Slot = Arc>; +pub(super) type Completion = Arc>>>; + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn start(device: &eframe::wgpu::Device, format: eframe::wgpu::TextureFormat, ctx: &egui::Context, slot: &Slot, completion: &Completion) { + let mut state = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*state, BuildState::NotStarted) { + return; + } + *state = BuildState::Building; + let (device, ctx, out) = (device.clone(), ctx.clone(), slot.clone()); + let worker_completion = completion.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor-build".into()).spawn(move || { + let threads = std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + photocraft_gpu::Compositor::try_new_with_format_threads_classified(&device, format, threads) + })); + let (result, outcome) = match result { + Ok(Ok(comp)) => (BuildState::Ready(Box::new(comp)), Ok(())), + Ok(Err(error)) => { + let deterministic = error.kind == photocraft_gpu::BuildFailureKind::DeterministicShaderCompilation; + (BuildState::Failed(photocraft_gpu::Unsupported(error.message.clone())), Err((error.message, deterministic))) + } + Err(_) => ( + BuildState::Failed(photocraft_gpu::Unsupported("building the GPU compositor panicked".into())), + Err(("building the GPU compositor panicked".into(), false)), + ), + }; + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = result; + *worker_completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(outcome); + ctx.request_repaint(); + }); + if let Err(error) = spawned { + record_spawn_failure(&mut state, completion, &error); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn record_spawn_failure(state: &mut BuildState, completion: &Completion, error: &std::io::Error) { + let message = format!("failed to spawn GPU compositor build thread: {error}"); + *state = BuildState::Failed(photocraft_gpu::Unsupported(message.clone())); + *completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err((message, false))); +} + +#[cfg(test)] +mod tests { + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn spawn_failure_is_reported_as_retryable() { + let completion = super::Completion::default(); + let mut state = super::BuildState::Building; + super::record_spawn_failure(&mut state, &completion, &std::io::Error::other("resource exhausted")); + assert!(matches!(state, super::BuildState::Failed(_))); + let outcome = completion.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take(); + assert_eq!(outcome, Some(Err(("failed to spawn GPU compositor build thread: resource exhausted".into(), false)))); + } + + #[test] + fn compositor_is_send() { + fn assert_send() {} + assert_send::(); + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs index 8de45748b6..61edb94bdb 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs @@ -66,6 +66,7 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { } // On the same device (see the top of the file). canvas_textures_the_gpu_refuses_leave_it_working(&gpu); + background_compositor_switch_preserves_pixels(&gpu); refused_viewport_page_work_is_caught(&gpu); } @@ -202,3 +203,36 @@ fn partial_updates_match_a_full_rebuild_at_every_mip_level() { } } } + +/// A CPU-created texture stays valid when a newly built compositor updates only a damage rect. +fn background_compositor_switch_preserves_pixels(gpu: &GpuCanvas) { + use photocraft_doc::{ColorMode, Document, Layer, LayerContent}; + let mut doc = Document::new("background build", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("pixels", doc.pixel_format()); + if let LayerContent::Raster(surface) = &mut layer.content { + surface.fill_rect(Rect::new(0, 0, 64, 64), &[0.2, 0.4, 0.6, 1.0]); + } + doc.layers.push(layer); + let key = doc.id.0; + *gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Building; + let first = gpu.refresh(key, &doc, None, None); + assert_eq!(first.kind, "full"); + assert_eq!(first.fallback, Some(CpuReason::CompositorBuilding)); + assert!(gpu.compositor_failed().is_none()); + let built = photocraft_gpu::Compositor::try_new_with_format(&gpu.rs.device, photocraft_gpu::Compositor::preferred_acc_format(&gpu.rs.adapter)); + let Ok(comp) = built else { return }; + *gpu.compositor_build.lock().unwrap() = compositor_build::BuildState::Ready(Box::new(comp)); + let damage = Rect::new(8, 8, 16, 16); + if let LayerContent::Raster(surface) = &mut doc.layers[0].content { + surface.fill_rect(damage, &[0.8, 0.2, 0.1, 1.0]); + } + let second = gpu.refresh(key, &doc, Some(damage), None); + assert_eq!(second.kind, "gpu-rect", "{:?}", second.fallback); + let actual = read_mips(gpu, key).remove(0); + let expected = photocraft_compose::render(&doc, doc.bounds()); + for (got, want) in actual.iter().zip(expected.px.iter()) { + for (g, w) in got.iter().zip(want.iter()) { + assert!((g - w).abs() < 0.02, "{got:?} != {want:?}"); + } + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs index 58b12774b2..a633e67300 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs @@ -26,6 +26,8 @@ pub struct Perf { /// path the cost of *encoding and submitting* the composite — not of running it, which is /// `gpu_ms`. pub composite_ms: f64, + /// Time spent on a failed GPU composite attempt before CPU fallback. + pub gpu_attempt_ms: f64, /// Last canvas refresh: conversion + upload (ms). pub upload_ms: f64, /// Last canvas refresh kind: "full", "rect" or "none". @@ -219,6 +221,9 @@ impl Perf { self.last_refresh_px = px; self.composite_ms = composite_ms; self.upload_ms = upload_ms; + // A refresh that didn't come through `record_refresh` or `record_gpu_attempt` had no failed + // GPU attempt: the last one's time would be read as this refresh's. + self.gpu_attempt_ms = 0.0; if !kind.starts_with("gpu") { self.gpu_uploads = 0; // The CPU compositor did this one: there is no GPU time, and the last GPU @@ -231,11 +236,19 @@ impl Perf { /// Record a canvas refresh and any GPU measurement that came back with it. pub fn record_refresh(&mut self, r: &Refresh) { self.record(r.kind, r.px, r.composite_ms, r.upload_ms); + self.record_gpu_attempt(r.gpu_attempt_ms); if let Some(t) = r.gpu { self.record_gpu(t); } } + /// Record the time a failed GPU composite attempt took before the CPU did the refresh just + /// recorded, apart from that refresh's own CPU time. + pub fn record_gpu_attempt(&mut self, ms: f64) { + self.gpu_attempt_ms = ms; + self.stage.composite_ms += ms; + } + /// Record a GPU composite measurement that came back (see [`photocraft_gpu::GpuTiming`]). pub fn record_gpu(&mut self, t: photocraft_gpu::GpuTiming) { self.gpu_ms = Some(t.ms); diff --git a/crates/ui-egui/src/canvas/gpu_canvas/setup.rs b/crates/ui-egui/src/canvas/gpu_canvas/setup.rs index b9d3f98bbe..8565cd8f7f 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/setup.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/setup.rs @@ -32,6 +32,8 @@ pub struct Refresh { /// Pixels refreshed. pub px: u64, pub composite_ms: f64, + /// Time spent on a failed GPU composite attempt before CPU fallback. + pub gpu_attempt_ms: f64, pub upload_ms: f64, /// Layer tiles the wgpu compositor uploaded. pub uploads: u64, diff --git a/crates/ui-egui/src/canvas/gpu_canvas/tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/tests.rs index 74305c024b..858b3691b4 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/tests.rs @@ -397,3 +397,30 @@ fn shaders_validate() { .unwrap_or_else(|e| panic!("{name}: {e:?}")); } } + +#[test] +fn failed_gpu_attempt_is_recorded_separately_and_counted_in_frame_time() { + let mut perf = Perf::default(); + perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() }); + assert_eq!(perf.composite_ms, 2.0); + assert_eq!(perf.gpu_attempt_ms, 7.0); + assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 12.0); +} + +#[test] +fn a_refresh_recorded_directly_has_no_gpu_attempt_of_its_own() { + let mut perf = Perf::default(); + perf.record_refresh(&Refresh { kind: "full", composite_ms: 2.0, upload_ms: 3.0, gpu_attempt_ms: 7.0, ..Default::default() }); + perf.record("rect", 10, 1.0, 1.0); + assert_eq!(perf.gpu_attempt_ms, 0.0); +} + +#[test] +fn a_deferred_redraws_gpu_attempt_is_counted_once_when_it_ends() { + let mut perf = Perf::default(); + perf.record("background", 10, 2.0, 0.0); + perf.record_gpu_attempt(7.0); + assert_eq!(perf.composite_ms, 2.0); + assert_eq!(perf.gpu_attempt_ms, 7.0); + assert_eq!(perf.stage.composite_ms + perf.stage.upload_ms, 9.0); +} diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 9184abe989..84b4f785dc 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -202,6 +202,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let tool = crate::canvas::event_tool(app, mods); let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods); let crop_tol = crate::crop_ui::tolerance(app); + let straightens = tool == Tool::Crop && app.crop.drag.is_none() && (app.crop.straighten.armed || mods.command); let gesture = if let Some(t) = &app.ui.transform { use crate::transform_tool::Hit; let h = crate::transform_tool::box_hit(app, t, p, crate::transform_tool::handle_tolerance(app)); @@ -231,6 +232,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { } else if tool == Tool::Move || moves_layer { let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default(); moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude)) + } else if straightens { + // A straighten line (`crop_straighten::pointer` takes the press before the frame's drags) + // snaps its ends as points wherever it starts, never by the offset of a frame or image it + // doesn't move. + Some((Gesture::Point, Vec::new())) } else if tool == Tool::Crop && (crate::crop_mode::classic(app) || app.crop.default_frame) && let Some(rect) = @@ -297,7 +303,10 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { } else { SnapTargets::default() }; - app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); + // Windows Ctrl both starts a straighten line and is held while it is drawn: the override then + // covers the press too, or the line's start would snap and its end not. + let disabled = straightens && override_held(mods); + app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled }); } /// Snap a guide drag that ⌘ with a selection tool starts at `p` (`canvas::command_guide_at`, @@ -524,6 +533,18 @@ fn follow_reposition(app: &mut PhotocraftApp) { } } +/// A click on a crop frame or one of its handles (released where it was pressed, the frame not +/// moved) changes nothing, as a Move-tool click doesn't move the layer (`canvas::tool_event`): +/// snapping the release would pull an edge within reach onto a nearby one. +fn crop_click(app: &PhotocraftApp, p: [f64; 2]) -> bool { + use crate::crop_ui::CropDrag; + let Some(snap) = app.prefs_rt.snap.as_ref() else { return false }; + let Some(CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } | CropDrag::MoveImage { rect, .. }) = app.crop.drag else { + return false; + }; + app.ui.tool == Tool::Crop && p == snap.start && app.ui.crop_rect == Some(rect) +} + /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { @@ -564,6 +585,11 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + if crop_click(app, [x, y]) { + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + return ev; + } follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { diff --git a/crates/ui-egui/src/canvas/snap_ui/tests.rs b/crates/ui-egui/src/canvas/snap_ui/tests.rs index 17ba7ca349..b42264a188 100644 --- a/crates/ui-egui/src/canvas/snap_ui/tests.rs +++ b/crates/ui-egui/src/canvas/snap_ui/tests.rs @@ -93,6 +93,77 @@ fn drawing_a_new_crop_snaps_its_corner_not_the_default_frame() { } } +/// A straighten line drawn inside an upright, edited frame levels the frame on the line as +/// drawn: it isn't shifted by the offset that would snap the frame if the press moved it. The +/// default mode moves the image there, which never shifted the line; it stays as it was. +#[test] +fn a_straighten_line_inside_the_frame_is_not_snapped_as_a_frame_move() { + let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + for (classic, armed, m) in [(true, true, egui::Modifiers::NONE), (true, false, mac_cmd), (false, true, egui::Modifiers::NONE)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.view.show.smart_guides = true; + crate::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([20.0, 20.0, 120.0, 70.0]); + app.crop.default_frame = false; + if classic { + // Without Straighten the press moves the frame and snaps its edges, as on main. + crate::snap_ui::begin(&mut app, [30.0, 30.0], egui::Modifiers::NONE); + let gesture = app.prefs_rt.snap.take().map(|s| s.gesture); + assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 70.0] })); + } + app.crop.straighten.armed = armed; + // Moving the frame by the line's (100, 8) would bring its centre 3 px from the + // document's: that offset must not reach the line. Both ends are clear of every target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 38.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 38.0 }, m); + let want = crate::crop_straighten::frame_angle([30.0, 30.0], [130.0, 38.0], 0.0).unwrap(); + let got = crate::crop_ui::angle(&app); + assert!((got - want).abs() < 1e-9, "Classic Mode {classic}, armed {armed}: {got} vs {want}"); + } +} + +/// Windows Ctrl both starts a straighten line and is held while drawing it, which turns +/// snapping off: the press stays where it was too, so the frame levels on the line as drawn, +/// inside an edited frame, outside it and in the untouched default frame. ⌘ on the Mac is not +/// the override: both ends snap. +#[test] +fn a_windows_ctrl_straighten_line_does_not_snap_its_start_either() { + let win_ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + let mac_cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + for frame in [Some([20.0, 20.0, 120.0, 70.0]), Some([130.0, 20.0, 190.0, 70.0]), None] { + for (m, start) in [(win_ctrl, [97.0, 30.0]), (mac_cmd, [100.0, 30.0])] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.extras.snap = true; + crate::crop_ui::ensure_frame(&mut app); + if let Some(r) = frame { + app.ui.crop_rect = Some(r); + app.crop.default_frame = false; + } + // The press is 3 px from the document's centre line; the end is clear of every target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 97.0, y: 30.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 180.0, y: 38.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 180.0, y: 38.0 }, m); + let want = crate::crop_straighten::frame_angle(start, [180.0, 38.0], 0.0).unwrap(); + let got = crate::crop_ui::angle(&app); + assert!((got - want).abs() < 1e-9, "frame {frame:?}, {m:?}: {got} vs {want}"); + } + } +} + #[test] fn move_tool_snaps_to_other_layer_edges_with_smart_guides() { let mut app = app_with_box(); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6f63a2d200..7882fc43c9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -143,7 +143,7 @@ fn a_channel_overlay_follows_a_size_changing_preview() { canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), view.clone(), true); }); output.textures_delta.clear(); - let overlay = crate::channel_view::ensure(&mut app, &ctx, 0).expect("the channel overlay"); + let overlay = crate::channel_view::ensure(&mut app, &ctx, 0, None).expect("the channel overlay"); let mesh = output .shapes .iter() @@ -1729,7 +1729,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); ctx.input_mut(|i| i.max_texture_side = 256); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let revision = app.session.documents()[0].revision; let steps = app.session.documents()[0].history.past_len(); @@ -1740,7 +1740,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { tool_event(&mut app, ToolEvent::Move { x, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; assert!(shown.layer(layer).unwrap().mask.as_ref().unwrap().value(x as i32, 30) < 0.1); - assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0), Some(tex)); + assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0, None), Some(tex)); let mut delta = ctx.tex_manager().write().take_delta(); let uploads = delta.set.get(&tex).unwrap_or_else(|| panic!("{depth}-bit {mode}: live mask texture must upload before release at {x}")); assert_eq!(uploads.len(), 1); @@ -1765,7 +1765,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(app.session.documents()[0].revision, revision); assert_eq!(app.session.documents()[0].history.past_len(), steps); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); assert!(ctx.tex_manager().write().take_delta().set.is_empty(), "idle frame does not upload"); } tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, egui::Modifiers::NONE); @@ -1773,7 +1773,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(st.history.past_len(), steps + 1); assert!(st.doc.layer(layer).unwrap().mask.as_ref().unwrap().value(50, 30) < 0.1); assert_eq!(st.doc.layer(layer).unwrap().content, original.content); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); // The commit takes the live stroke's pixels, which the texture already shows: at // most a partial upload, never the whole mask again. @@ -1781,7 +1781,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { delta.clear(); app.session.undo(); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; let egui::ImageData::Color(image) = &upload.image; @@ -1826,14 +1826,14 @@ fn channel_views_show_live_targets_and_discarded_strokes() { app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let original = app.session.documents()[0].doc.clone(); let steps = app.session.documents()[0].history.past_len(); tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).expect("live channel upload")[0]; let pos = upload.pos.expect("partial channel upload"); @@ -1872,7 +1872,7 @@ fn channel_views_show_live_targets_and_discarded_strokes() { // document revision did not change. There is no committed damage rectangle to use. app.drag = None; app.live_stroke = None; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; assert!(upload.pos.is_none(), "discarding a preview refreshes the view"); diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs index 455f2edb56..570549e799 100644 --- a/crates/ui-egui/src/canvas/textures.rs +++ b/crates/ui-egui/src/canvas/textures.rs @@ -578,7 +578,7 @@ pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz 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()); + crate::background_redraw::start(app, ctx, &gpu, doc.clone(), display.clone(), visible, region, r.fallback.clone(), r.gpu_attempt_ms); } 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); diff --git a/crates/ui-egui/src/canvas/tool_cursor.rs b/crates/ui-egui/src/canvas/tool_cursor.rs index e4a81bdc49..84b69b26cf 100644 --- a/crates/ui-egui/src/canvas/tool_cursor.rs +++ b/crates/ui-egui/src/canvas/tool_cursor.rs @@ -12,6 +12,7 @@ use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; use crate::canvas::ViewXform; use crate::state::Tool; +mod bucket; mod hand; mod pen; mod zoom; @@ -39,6 +40,9 @@ enum Shape { Outline { rings: Arc<[Vec]>, centre: bool }, } +/// The visible canvas crosshair used by the Gradient tool and other standard crosshair tools. +const STANDARD_CROSSHAIR: Shape = Shape::Crosshair { length: 8.0, gap: 2.0 }; + #[derive(Clone)] struct Cached { shape: Shape, @@ -80,6 +84,11 @@ impl egui::plugin::Plugin for CursorLifecycle { } } +/// The bucket artwork and its exact fill crosshair move as one native cursor or painted image. +pub(crate) fn bucket(painter: &Painter, at: Pos2) -> Result { + bucket::show(painter, at) +} + pub(crate) fn circle(painter: &Painter, at: Pos2, radius: f32, centre: bool) -> CursorIcon { show(painter, at, Shape::Circle { radius, centre }) } @@ -88,6 +97,10 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C show(painter, at, Shape::Crosshair { length, gap }) } +pub(crate) fn standard_crosshair(painter: &Painter, at: Pos2) -> CursorIcon { + show(painter, at, STANDARD_CROSSHAIR.clone()) +} + pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec>, centre: bool) -> CursorIcon { show(painter, at, Shape::Outline { rings: rings.into(), centre }) } diff --git a/crates/ui-egui/src/canvas/tool_cursor/bucket.rs b/crates/ui-egui/src/canvas/tool_cursor/bucket.rs new file mode 100644 index 0000000000..41d37ea1f3 --- /dev/null +++ b/crates/ui-egui/src/canvas/tool_cursor/bucket.rs @@ -0,0 +1,102 @@ +//! One image owns the bucket artwork and its lower-right fill target. Splitting those between +//! the frame and the OS pointer lets them drift apart whenever a frame arrives after the pointer. + +use super::{CursorIcon, CustomCursorImage, Painter, Pos2, native, native_scale}; +use std::sync::Arc; + +const SIZE: f32 = 22.0; +const HOT: egui::Vec2 = egui::vec2(26.0 / 24.0 * SIZE, 25.0 / 24.0 * SIZE); + +#[derive(Clone)] +struct Cached { + scale: f32, + image: Result, + texture: Option, +} + +pub(super) fn show(painter: &Painter, at: Pos2) -> Result { + let ctx = painter.ctx(); + let native = native(ctx); + let scale = if native { native_scale(ctx) } else { ctx.pixels_per_point() }; + let id = egui::Id::unique("photocraft-bucket-cursor"); + let mut cached = ctx.data(|d| d.get_temp::(id)).filter(|cached| cached.scale == scale).unwrap_or_else(|| Cached { + scale, + // Keep diagnostics in the log once per cached failure; the error also reaches UI text. + image: rasterize(scale).inspect_err(|error| log::error!("{error}\n{}", std::backtrace::Backtrace::force_capture())), + texture: None, + }); + let result = match &cached.image { + Err(error) => Err(error.clone()), + Ok(image) => { + if native { + ctx.set_cursor_image(Some(image.clone())); + } else { + let texture = cached.texture.get_or_insert_with(|| { + let size = image.size.map(usize::from); + ctx.load_texture("Paint Bucket cursor", egui::ColorImage::from_rgba_unmultiplied(size, &image.rgba), egui::TextureOptions::LINEAR) + }); + let size = egui::vec2(f32::from(image.size[0]), f32::from(image.size[1])) / scale; + // A texture's texel centre is half a physical pixel into its cell. Keep that + // centre exactly on the pointer, just as the native bitmap hotspot is. + let hot = (egui::vec2(f32::from(image.hotspot[0]), f32::from(image.hotspot[1])) + egui::Vec2::splat(0.5)) / scale; + let rect = egui::Rect::from_min_size(at - hot, size); + egui::Area::new(egui::Id::unique("pc-tool-cursor")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false).show( + ctx, + |ui| { + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + }, + ); + } + Ok(CursorIcon::None) + } + }; + ctx.data_mut(|d| d.insert_temp(id, cached)); + result +} + +pub(super) fn rasterize(scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err(format!("Invalid Paint Bucket cursor scale: {scale}")); + } + // Round the hotspot itself, rather than the box, so it always lands on an exact bitmap pixel. + // Keep one logical pixel of the existing icon halo and its antialiasing fringe inside the box. + let margin = (scale + 1.0).ceil(); + let hot = [(HOT.x * scale).round() + margin, (HOT.y * scale).round() + margin]; + let cross = super::rasterize(&super::STANDARD_CROSSHAIR, scale).ok_or_else(|| format!("Paint Bucket crosshair does not fit a cursor at scale {scale}"))?; + let size = [hot[0] + f32::from(cross.size[0] - cross.hotspot[0]), hot[1] + f32::from(cross.size[1] - cross.hotspot[1])]; + if size.iter().any(|side| !side.is_finite() || *side > f32::from(super::MAX_SIDE)) { + return Err(format!("Paint Bucket cursor does not fit the platform bitmap limit at scale {scale}")); + } + let size = size.map(|side| side as u16); + let hotspot = hot.map(|coord| coord as u16); + let mut pixels = vec![egui::Color32::TRANSPARENT; usize::from(size[0]) * usize::from(size[1])]; + let icon = crate::icons::rasterize("paint-bucket", (SIZE * scale).round() as u32)?; + let margin = margin as i32; + let halo = scale.round() as i32; + // The same eight dark halo offsets and white SVG as icons::cursor, now in the image that + // also owns the crosshair. Color32 keeps composition premultiplied until the final upload. + for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1), (-1, 1), (1, -1), (0, 0)] { + for (i, pixel) in icon.pixels.iter().enumerate() { + let x = (i % icon.size[0]) as i32 + margin + dx * halo; + let y = (i / icon.size[0]) as i32 + margin + dy * halo; + let color = if dx == 0 && dy == 0 { *pixel } else { egui::Color32::from_black_alpha((f32::from(pixel.a()) * 200.0 / 255.0).round() as u8) }; + over(&mut pixels, size, x, y, color); + } + } + for (i, pixel) in cross.rgba.as_chunks::<4>().0.iter().enumerate() { + let x = (i % usize::from(cross.size[0])) as i32 + i32::from(hotspot[0]) - i32::from(cross.hotspot[0]); + let y = (i / usize::from(cross.size[0])) as i32 + i32::from(hotspot[1]) - i32::from(cross.hotspot[1]); + over(&mut pixels, size, x, y, egui::Color32::from_rgba_unmultiplied(pixel[0], pixel[1], pixel[2], pixel[3])); + } + let rgba = pixels.into_iter().flat_map(|pixel| pixel.to_srgba_unmultiplied()).collect::>(); + Ok(CustomCursorImage { rgba: Arc::from(rgba), size, hotspot }) +} + +fn over(pixels: &mut [egui::Color32], size: [u16; 2], x: i32, y: i32, foreground: egui::Color32) { + if x < 0 || y < 0 || x >= i32::from(size[0]) || y >= i32::from(size[1]) { + return; + } + if let Some(background) = pixels.get_mut(y as usize * usize::from(size[0]) + x as usize) { + *background = background.blend(foreground); + } +} diff --git a/crates/ui-egui/src/canvas/tool_cursor/tests.rs b/crates/ui-egui/src/canvas/tool_cursor/tests.rs index f058b84b2c..bd5b40c97e 100644 --- a/crates/ui-egui/src/canvas/tool_cursor/tests.rs +++ b/crates/ui-egui/src/canvas/tool_cursor/tests.rs @@ -10,6 +10,11 @@ fn alpha(image: &CustomCursorImage, x: u16, y: u16) -> u8 { image.rgba[(usize::from(y) * usize::from(image.size[0]) + usize::from(x)) * 4 + 3] } +fn rgba_at(image: &CustomCursorImage, x: u16, y: u16) -> [u8; 4] { + let offset = (usize::from(y) * usize::from(image.size[0]) + usize::from(x)) * 4; + image.rgba[offset..offset + 4].try_into().unwrap() +} + fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { let mut output = ctx.run_ui(Default::default(), draw); // These tests inspect cursor output without a renderer. @@ -17,6 +22,20 @@ fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutp output } +#[cfg(target_os = "linux")] +fn collect_meshes(shape: &egui::Shape, meshes: &mut Vec<(egui::TextureId, egui::Rect)>) { + match shape { + egui::Shape::Mesh(mesh) => meshes.push((mesh.texture_id, mesh.calc_bounds())), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_meshes(shape, meshes); + } + } + egui::Shape::Noop => {} + other => panic!("unexpected non-image cursor fallback primitive: {other:?}"), + } +} + #[test] fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() { for scale in [1.0, 1.25, 1.5, 2.0, 3.0] { @@ -48,6 +67,161 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } +#[test] +fn bucket_bitmap_keeps_the_svg_above_left_of_its_exact_crosshair_hotspot() { + for scale in [1.0, 1.25, 2.0] { + let image = super::bucket::rasterize(scale).unwrap(); + let expected_x = (26.0 / 24.0 * 22.0 * scale).round() + (scale + 1.0).ceil(); + let expected_y = (25.0 / 24.0 * 22.0 * scale).round() + (scale + 1.0).ceil(); + assert_eq!(image.hotspot, [expected_x as u16, expected_y as u16], "bucket box target at {scale}x"); + let gradient_cross = rasterize(&STANDARD_CROSSHAIR, scale).unwrap(); + assert_eq!(alpha(&image, image.hotspot[0], image.hotspot[1]), 0, "Gradient-style open center at {scale}x"); + + let side = usize::from(image.size[0]); + let has_bucket_art = image.rgba.as_chunks::<4>().0.iter().enumerate().any(|(i, p)| { + let x = (i % side) as u16; + let y = (i / side) as u16; + p[3] > 200 && x + 2 < image.hotspot[0] && y + 2 < image.hotspot[1] + }); + assert!(has_bucket_art, "opaque bucket art remains above-left of the hotspot at {scale}x"); + + for arm in [1, 5, 8] { + let arm = (arm as f32 * scale).round() as u16; + assert_eq!( + rgba_at(&image, image.hotspot[0] + arm, image.hotspot[1]), + rgba_at(&gradient_cross, gradient_cross.hotspot[0] + arm, gradient_cross.hotspot[1]), + "right arm matches the Gradient crosshair at {scale}x" + ); + assert_eq!( + rgba_at(&image, image.hotspot[0], image.hotspot[1] + arm), + rgba_at(&gradient_cross, gradient_cross.hotspot[0], gradient_cross.hotspot[1] + arm), + "lower arm matches the Gradient crosshair at {scale}x" + ); + } + } +} + +#[test] +fn bucket_rasterization_reports_invalid_scales_and_missing_artwork() { + for scale in [0.0, f32::NAN, f32::INFINITY] { + let error = super::bucket::rasterize(scale).unwrap_err(); + assert!(error.contains("Invalid Paint Bucket cursor scale"), "{scale}: {error}"); + } + let error = crate::icons::rasterize("missing-paint-bucket", 22).unwrap_err(); + assert!(error.contains("Cursor icon \"missing-paint-bucket\" is missing"), "{error}"); +} + +#[test] +fn bucket_cursor_errors_preserve_the_concise_rasterization_message_across_frames() { + let ctx = egui::Context::default(); + ctx.set_zoom_factor(100.0); + let mut errors = Vec::new(); + for _ in 0..2 { + let output = frame(&ctx, |ui| { + let scale = if native(ui.ctx()) { native_scale(ui.ctx()) } else { ui.ctx().pixels_per_point() }; + let expected = super::bucket::rasterize(scale).unwrap_err(); + let error = super::bucket(ui.painter(), egui::pos2(100.0, 100.0)).unwrap_err(); + assert_eq!(error, expected, "the dialog and status receive only the original rasterization error"); + assert_eq!(error.lines().count(), 1, "diagnostic backtraces belong in the log"); + errors.push(error); + }); + assert!(output.platform_output.cursor_image.is_none(), "a failed rasterization never installs a cursor"); + } + assert_eq!(errors[0], errors[1], "the cached failure stays stable across frames"); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_bucket_cursor_error_leaves_the_status_bar_when_the_cursor_recovers() { + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(100.0); + frame(&ctx, |ui| { + crate::canvas::paint_bucket_cursor(&mut app, ui.painter(), pos2(100.0, 100.0)); + }); + assert!(app.ui.status_error, "the failure reaches the status bar"); + assert!(app.ui.status.contains("Paint Bucket"), "{}", app.ui.status); + + ctx.set_zoom_factor(1.0); + for _ in 0..2 { + frame(&ctx, |ui| { + crate::canvas::paint_bucket_cursor(&mut app, ui.painter(), pos2(100.0, 100.0)); + }); + } + assert!(!app.ui.status_error, "the cursor works again"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn bucket_cursor_reuses_the_native_bitmap_while_the_hotspot_moves() { + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + let cursor = |at| { + let output = frame(&ctx, |ui| { + let icon = super::bucket(ui.painter(), at).unwrap(); + ui.ctx().set_cursor_icon(icon); + }); + output.platform_output.cursor_image.expect("root desktop viewport provides the bucket bitmap") + }; + + let first = cursor(pos2(100.0, 100.0)); + let moved = cursor(pos2(137.0, 163.0)); + assert!(Arc::ptr_eq(&first.rgba, &moved.rgba), "moving the pointer reuses the rasterized bitmap"); + assert_eq!(first.hotspot, moved.hotspot); +} + +#[test] +#[cfg(target_os = "linux")] +fn scaled_wayland_bucket_uses_one_cached_image_area_without_a_split_crosshair() { + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + set_wayland(&ctx, true); + let run = |native_ppp: f32, at: egui::Pos2| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp); + let mut output = ctx.run_ui(input, |ui| { + let icon = super::bucket(ui.painter(), at).unwrap(); + ui.ctx().set_cursor_icon(icon); + }); + output.textures_delta.clear(); + let mut images = Vec::new(); + for shape in &output.shapes { + collect_meshes(&shape.shape, &mut images); + } + (output, images) + }; + + let first_at = pos2(100.0, 100.0); + run(1.25, first_at); // egui's first Area pass measures content and emits no shapes. + let (first, first_images) = run(1.25, first_at); + assert!(first.platform_output.cursor_image.is_none(), "scaled Wayland does not send a bitmap to winit"); + assert_eq!(first.platform_output.cursor_icon, CursorIcon::None, "the image replaces the system arrow"); + assert_eq!(first_images.len(), 1, "bucket and fill crosshair are one Image primitive"); + let image = super::bucket::rasterize(1.25).unwrap(); + let image_bounds = first_images[0].1; + let image_hotspot = image_bounds.min + egui::vec2((f32::from(image.hotspot[0]) + 0.5) / 1.25, (f32::from(image.hotspot[1]) + 0.5) / 1.25); + assert_eq!(image_hotspot, first_at, "the center of the hotspot texel stays on the pointer at 1.25x"); + let moved_at = pos2(137.0, 163.0); + let (moved, moved_images) = run(1.25, moved_at); + assert_eq!(moved_images.len(), 1); + assert_eq!(moved_images[0].0, first_images[0].0, "the single scaled texture is cached while it moves"); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-tool-cursor")); + assert!(ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)), "one cursor Area owns the image"); + assert!( + !ctx.memory(|memory| memory.areas().visible_layer_ids().iter().any(|id| id.id == egui::Id::unique("pc-icon-cursor"))), + "the old icon overlay is absent" + ); + let mut moved_shapes = Vec::new(); + for shape in &moved.shapes { + collect_meshes(&shape.shape, &mut moved_shapes); + } + assert_eq!(moved_shapes.len(), 1, "there are no independent crosshair line primitives"); + let moved_bounds = moved_images[0].1; + let moved_hotspot = moved_bounds.min + egui::vec2((f32::from(image.hotspot[0]) + 0.5) / 1.25, (f32::from(image.hotspot[1]) + 0.5) / 1.25); + assert_eq!(moved_hotspot, moved_at, "the cached image keeps its hotspot aligned after movement"); +} + /// On Linux a tip stays within what X servers and hardware cursor planes take (256 px); a larger /// one falls back to the painted outline. Windows and macOS take far larger cursors. #[test] @@ -352,6 +526,41 @@ fn write_cursor_preview() { std::fs::write(path, png).unwrap(); } +/// Run explicitly to inspect the unified bucket bitmap at 1× and 2× against three backgrounds. +#[test] +#[ignore = "writes tmp/cursor-verification/unified-bucket-preview.png for visual inspection"] +fn write_bucket_cursor_preview() { + let (width, height) = (600_usize, 300_usize); + let mut rgba = vec![255; width * height * 4]; + let backgrounds = [32_u8, 128, 255]; + for (col, background) in backgrounds.into_iter().enumerate() { + for y in 0..height { + for x in col * 200..(col + 1) * 200 { + rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(background); + } + } + for (row, scale) in [1.0, 2.0].into_iter().enumerate() { + let image = super::bucket::rasterize(scale).unwrap(); + let side = usize::from(image.size[0]); + let pointer = (col * 200 + 100, row * 150 + 75); + let x0 = pointer.0 - usize::from(image.hotspot[0]); + let y0 = pointer.1 - usize::from(image.hotspot[1]); + for (i, pixel) in image.rgba.as_chunks::<4>().0.iter().enumerate() { + let at = ((y0 + i / side) * width + x0 + i % side) * 4; + let a = f32::from(pixel[3]) / 255.0; + for channel in 0..3 { + rgba[at + channel] = (f32::from(pixel[channel]) * a + f32::from(background) * (1.0 - a)).round() as u8; + } + } + } + } + let image = photocraft_codecs::Image::from_u8(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp/cursor-verification/unified-bucket-preview.png"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, png).unwrap(); +} + #[cfg(not(target_arch = "wasm32"))] fn harness() -> Harness<'static, crate::PhotocraftApp> { let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 6db5aace3e..0b47c8447f 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -61,6 +61,12 @@ fn preflight_tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modi { crate::guide_select::clear(app); } + // A tool key pressed mid-drag ends the crop gesture here: a later handler can take every event + // of the new tool, and a drag left set keeps the frame from committing on leave. It ends as a + // release would, so a box drawn over the untouched default frame is committed, not dropped (#9223). + if app.ui.tool != Tool::Crop { + crate::crop_ui::end_drag(app); + } if crate::symmetry_ui::pointer(app, ev, crate::workspace_ui::sticky_mods(app, mods)) { return None; } diff --git a/crates/ui-egui/src/caps_lock_tests.rs b/crates/ui-egui/src/caps_lock_tests.rs index eee2eb5053..f4f8f5d72f 100644 --- a/crates/ui-egui/src/caps_lock_tests.rs +++ b/crates/ui-egui/src/caps_lock_tests.rs @@ -164,6 +164,9 @@ fn caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair() { assert_eq!(img.size, expected.size, "{tool:?}: bitmap dimensions match rasterized variant"); assert_eq!(img.hotspot, expected.hotspot, "{tool:?}: hotspot matches variant"); assert_eq!(img.rgba, expected.rgba, "{tool:?}: RGBA bitmap matches variant rasterization"); + } else if tool == Tool::PaintBucket && cfg!(not(target_arch = "wasm32")) { + assert_eq!(standard.icon, CursorIcon::Crosshair, "PaintBucket: the OS bitmap has a visible fallback"); + assert!(standard.image.is_some(), "PaintBucket: the standard cursor is the unified OS bitmap"); } else { assert_eq!(standard.icon, CursorIcon::None, "{tool:?}: the standard cursor is the tool's own icon, drawn"); assert!(standard.image.is_none(), "{tool:?}: painted fallback has no native cursor bitmap"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ddd3b7347..0cfeb7fdc1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -317,6 +317,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id) -> Option { let mut outcome: Option = None; match d.kind { + _ if merge_channels_ui::owns(fields) => merge_channels_ui::body(ui, fields), _ if crate::file_ui::owns(fields) => crate::file_ui::body(app, ui, fields, d.id), _ if crate::color_picker_ui::owns(fields) => { outcome = crate::color_picker_ui::body(ui, fields); @@ -454,7 +455,7 @@ fn button_row( } else { let ok_label = ok_label(d); // Dialogs that need an input first (a preset name, a selection) grey OK out. - let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields); + let can_ok = crate::crop_presets::can_confirm(app, fields) && crate::stack_dialog::can_confirm(fields) && merge_channels_ui::can_confirm(fields); let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok); // Photoshop: holding Alt turns Cancel into Reset (the dialog stays open). // Dialogs opt in via `dialog_alt_reset::supported` (per marker). @@ -860,6 +861,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::stroke_ui::owns, crate::channel_options_ui::owns, crate::channel_options_ui::owns_new_spot, + merge_channels_ui::owns, crate::shape_dialog::owns, crate::delete_layer_prompt::owns, crate::disk_change_prompt::owns, @@ -1011,6 +1013,8 @@ pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) { crate::guide_layout_ui::validate(app, &d.fields)?; } + // Merge Channels with too few, repeated, closed or changed documents stays open (#11451). + merge_channels_ui::preflight(app, id)?; Ok(()) } @@ -1054,6 +1058,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result /// The rest of the command dialogs: files, colour, preferences and export, then any other command. fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result { match d.kind { + _ if merge_channels_ui::owns(&d.fields) => merge_channels_ui::confirm(app, &d.fields), _ if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields), _ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields), _ if crate::color_picker_ui::owns(&d.fields) => { @@ -1212,6 +1217,7 @@ pub mod layer_picker; pub mod layer_style; pub mod liquify_ui; pub mod mask_trash_prompt; +pub mod merge_channels_ui; pub mod new_doc_ui; pub mod new_group_ui; pub mod new_guide_ui; diff --git a/crates/ui-egui/src/dialogs/channel_options_ui.rs b/crates/ui-egui/src/dialogs/channel_options_ui.rs index 3a3453e840..834ef82022 100644 --- a/crates/ui-egui/src/dialogs/channel_options_ui.rs +++ b/crates/ui-egui/src/dialogs/channel_options_ui.rs @@ -5,6 +5,9 @@ //! //! On the layer mask (double-click its Channels row, or Mask Options… on its Layers thumbnail) //! it is Photoshop's Layer Mask Display Options: just the rubylith's colour and opacity. +//! +//! The Channels panel's New Channel… is the same dialog for a channel still to be made (name, +//! Masked / Selected Areas, colour, opacity); OK runs `channel.new` (#11451). use egui::{Align2, Sense, pos2, vec2}; use photocraft_doc::ColorIndicates; @@ -15,6 +18,8 @@ use crate::theme::Tokens; pub const COMMAND: &str = "channel.options"; const MARK: &str = "__channelOptions"; +pub const NEW_CHANNEL: &str = "channel.new"; +const NEW_CHANNEL_MARK: &str = "__newChannel"; pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -75,6 +80,28 @@ pub fn open(app: &mut PhotocraftApp, channel: &Value) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } +/// The New Channel fields: Channel Options' for a channel still to be made (the next "Alpha N" +/// name, Masked Areas, red at 50%), or `None` without a document. OK runs `channel.new`. +pub fn new_channel_fields(app: &PhotocraftApp) -> Option> { + let doc = &app.session.active()?.doc; + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert(NEW_CHANNEL_MARK.into(), json!(true)); + f.insert("__command".into(), json!(NEW_CHANNEL)); + f.insert("__label".into(), json!("New Channel…")); + f.insert("name".into(), json!(photocraft_engine::channel_cmds::next_alpha_name(doc, "Alpha"))); + f.insert("indicates".into(), json!("masked")); + f.insert("color".into(), json!("#ff0000")); + f.insert("opacity".into(), json!(50.0)); + Some(f) +} + +/// Opens New Channel, or `None` without a document. +pub fn open_new_channel(app: &mut PhotocraftApp) -> Option { + let f = new_channel_fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + /// The `channel.options` params for the dialog's fields: spot channels take `solidity`, the /// others `opacity`. pub fn params(f: &Map) -> Value { @@ -92,7 +119,7 @@ pub fn params(f: &Map) -> Value { } pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - app.run(COMMAND, params(f)) + app.run(if f.contains_key(NEW_CHANNEL_MARK) { NEW_CHANNEL } else { COMMAND }, params(f)) } fn label(ui: &mut egui::Ui, text: &str) { @@ -122,8 +149,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.indent("channel-options-indicates", |ui| { ui.radio_value(&mut indicates, "masked".to_string(), tl!("Masked Areas")); ui.radio_value(&mut indicates, "selected".to_string(), tl!("Selected Areas")); - // Quick Mask can't be a spot channel. - if !quick { + // Quick Mask can't be a spot channel; New Spot Channel… makes spot channels. + if !quick && !f.contains_key(NEW_CHANNEL_MARK) { ui.radio_value(&mut indicates, "spot".to_string(), tl!("Spot Color")); } }); diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 68f46af65f..acfaa16db1 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -216,6 +216,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result) -> Result, path: &str, source_profile: &Profile) -> Result { let out = export_document(doc, f, None, source_profile)?; + crate::app::ensure_memory_to_save(&out, path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, path, &settings(f))?; let write = app.services.write.as_mut().ok_or("no writer configured")?; @@ -1111,6 +1113,7 @@ fn confirm_layers(app: &mut PhotocraftApp, f: &Map, ids: &[photoc let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { let out = export_document(doc, &f, None, &source_profile)?; + crate::app::ensure_memory_to_save(&out, &path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, &path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/dialogs/merge_channels_ui.rs b/crates/ui-egui/src/dialogs/merge_channels_ui.rs new file mode 100644 index 0000000000..c7d97512a0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/merge_channels_ui.rs @@ -0,0 +1,365 @@ +//! Channels panel › Merge Channels…: the mode (RGB, CMYK or Lab Color) and, for each of its +//! channels, the open grayscale document that becomes it. Photoshop asks in two steps (the mode, +//! then the documents); here both are one dialog. Only the modes the open documents can fill are +//! offered (RGB and Lab need three, CMYK four), OK waits for a different document per channel, +//! and a merge that still fails opens the dialog again with the reason. OK runs the engine's +//! `channel.merge`, which closes the sources; agents call it directly with params (#11451). +//! +//! The dialog names documents by their [`photocraft_doc::DocId`], not their tab index: work that +//! runs while it is open (a script moving or closing a document) can shift the indices, and OK +//! must merge, and close, the documents that were picked. They are looked up again at OK. + +use photocraft_doc::ColorMode; +use photocraft_engine::channel_cmds; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub const COMMAND: &str = "channel.merge"; +const MARK: &str = "__mergeChannels"; +/// The most channels a mode has (CMYK). +const SLOTS: usize = 4; +const MODES: [(&str, &str); 3] = [("rgb", "RGB Color"), ("cmyk", "CMYK Color"), ("lab", "Lab Color")]; +const TOO_FEW: &str = "Not enough grayscale documents for this mode."; +const REPEATED: &str = "Pick a different document for each channel."; +const CLOSED: &str = "A picked document is no longer open."; +const CHANGED: &str = "A picked document is no longer a grayscale document of the same size."; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +fn mode_of(key: &str) -> ColorMode { + match key { + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Rgb, + } +} + +/// The modes `count` documents can fill, in the menu's order. +fn modes(count: usize) -> Vec<(String, &'static str)> { + MODES.iter().filter(|(key, _)| mode_of(key).color_channels() <= count).map(|(key, label)| (key.to_string(), *label)).collect() +} + +/// The documents that can be merged, by document id and name: the open grayscale ones of the +/// active document's size, the active one first, as the engine picks them without params. +fn sources(app: &PhotocraftApp) -> Vec<(u64, String)> { + let Some(active) = app.session.active() else { return Vec::new() }; + let docs = app.session.documents(); + let fits = |i: usize| docs.get(i).filter(|d| d.doc.mode == ColorMode::Grayscale && d.doc.size == active.doc.size); + let current = app.session.active_index(); + let others = (0..docs.len()).filter(|i| Some(*i) != current); + current.into_iter().chain(others).filter_map(|i| fits(i).map(|d| (d.doc.id.0, d.doc.name.clone()))).collect() +} + +fn mode_field(f: &Map) -> &str { + f.get("mode").and_then(Value::as_str).unwrap_or("rgb") +} + +fn sources_field(f: &Map) -> Vec<(u64, String)> { + f.get("__sources").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +/// The picked document ids, one per channel of the mode. +fn picks(f: &Map) -> Vec { + let all: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + all.into_iter().take(mode_of(mode_field(f)).color_channels()).collect() +} + +/// The dialog's fields: the first mode the documents can fill (RGB Color from three) and the +/// merge-able documents in order, one per channel, or `None` without a document. +pub fn fields(app: &PhotocraftApp) -> Option> { + app.session.active()?; + let sources = sources(app); + let picks: Vec = sources.iter().take(SLOTS).map(|(id, _)| *id).collect(); + let mode = modes(sources.len()).first().map_or_else(|| "rgb".to_string(), |(key, _)| key.clone()); + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Merge Channels…")); + f.insert("__sources".into(), json!(sources)); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), json!(picks)); + Some(f) +} + +/// Opens Merge Channels, or `None` without a document. +pub fn open(app: &mut PhotocraftApp) -> Option { + let f = fields(app)?; + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +/// Why OK is greyed (English, for `tl!`): the mode needs more documents than are offered, or the +/// picks repeat a document or name one the dialog did not offer. `None` when OK can merge, and +/// for another dialog's fields. +pub fn problem(f: &Map) -> Option<&'static str> { + if !owns(f) { + return None; + } + let n = mode_of(mode_field(f)).color_channels(); + let sources = sources_field(f); + let picks = picks(f); + if sources.len() < n || picks.len() < n { + return Some(TOO_FEW); + } + let mut seen = std::collections::HashSet::new(); + let offered = |p: &u64| sources.iter().any(|(id, _)| id == p); + if picks.iter().any(|p| !seen.insert(*p) || !offered(p)) { + return Some(REPEATED); + } + None +} + +/// Whether OK is live: always for other dialogs, here while [`problem`] finds none. +pub fn can_confirm(f: &Map) -> bool { + problem(f).is_none() +} + +/// The picked documents' current tab indices, looked up by id; refused (English, for `tl!`) +/// when one was closed, or is no longer a grayscale document of the others' size. +fn resolve(app: &PhotocraftApp, f: &Map) -> Result, &'static str> { + if let Some(why) = problem(f) { + return Err(why); + } + let docs = app.session.documents(); + let indices = picks(f).iter().map(|id| docs.iter().position(|d| d.doc.id.0 == *id).ok_or(CLOSED)).collect::, _>>()?; + let first = indices.first().and_then(|&i| docs.get(i)).map(|d| d.doc.size); + let fits = |i: &usize| docs.get(*i).is_some_and(|d| d.doc.mode == ColorMode::Grayscale && Some(d.doc.size) == first); + if indices.iter().all(fits) { Ok(indices) } else { Err(CHANGED) } +} + +/// Checked before the dialog closes (`dialogs::confirm_preflight`), so a refused OK keeps it +/// open with the reason under the pickers: too few or repeated documents, or a picked one that +/// was closed or changed while the dialog was open. +pub fn preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { + let Some(f) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| &d.fields).filter(|f| owns(f)) else { return Ok(()) }; + let Err(why) = resolve(app, f) else { return Ok(()) }; + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.insert("__error".into(), json!(why)); + } + Err(why.to_string()) +} + +/// The `channel.merge` params for the dialog's fields: the mode and the picked documents' current +/// indices, one per channel. +pub fn params(app: &PhotocraftApp, f: &Map) -> Result { + let documents = resolve(app, f).map_err(str::to_string)?; + Ok(json!({ "mode": mode_field(f), "documents": documents })) +} + +/// Runs the merge. Should it still be refused, the dialog opens again with its picks and the +/// reason, rather than going away. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let result = params(app, f).and_then(|p| app.run(COMMAND, p)); + if let Err(e) = &result { + let mut again = f.clone(); + again.insert("__error".into(), json!(e)); + app.ui.open_dialog(crate::state::DialogKind::Command, again); + } + result +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + let sources = sources_field(f); + let mut mode = mode_field(f).to_string(); + let modes = modes(sources.len()); + ui.horizontal(|ui| { + ui.label(tl!("Mode:")); + if crate::widgets::dropdown(ui, "merge-channels-mode", tl!("Mode:"), &mut mode, &modes, 160.0) { + f.insert("mode".into(), json!(mode)); + f.remove("__error"); + } + }); + crate::widgets::section_label(ui, tl!("Specify Channels:")); + crate::widgets::hairline(ui); + let options: Vec<(u64, &str)> = sources.iter().map(|(id, name)| (*id, name.as_str())).collect(); + let mut picks: Vec = f.get("documents").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut changed = false; + for (k, channel) in channel_cmds::color_names(mode_of(&mode)).iter().enumerate() { + let Some(pick) = picks.get_mut(k) else { break }; + ui.horizontal(|ui| { + let caption = format!("{}:", tl!(channel)); + ui.add_sized([84.0, 22.0], egui::Label::new(&caption)); + changed |= crate::widgets::dropdown_literal(ui, &format!("merge-channels-{k}"), &caption, pick, &options, 200.0); + }); + } + if changed { + f.insert("documents".into(), json!(picks)); + f.remove("__error"); + } + // Why OK is greyed, or why the last OK was refused (the dialog's reasons are catalogued; the + // engine's own are shown as they come). + let why: Option<&str> = problem(f); + let why = why.or_else(|| f.get("__error").and_then(Value::as_str)).map(|w| tl!(w).to_string()); + if let Some(why) = why { + ui.label(egui::RichText::new(why).color(Tokens::get(ui.ctx()).danger).size(11.0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app(docs: usize) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for _ in 0..docs { + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + } + app + } + + fn id(app: &PhotocraftApp, index: usize) -> u64 { + app.session.documents()[index].doc.id.0 + } + + fn offered(f: &Map) -> Vec { + modes(sources_field(f).len()).into_iter().map(|(key, _)| key).collect() + } + + /// The merged document's first pixel as 8-bit RGB. + fn merged_pixel(app: &PhotocraftApp) -> [u8; 3] { + let doc = &app.session.active().unwrap().doc; + let px = photocraft_compose::render(doc, doc.bounds()).px[0]; + [px[0], px[1], px[2]].map(|v| (v * 255.0).round() as u8) + } + + /// Three grayscale documents filled black, mid gray and white, then the active one is `active`. + fn shaded(active: usize) -> PhotocraftApp { + let mut app = app(3); + for (i, gray) in ["#000000", "#808080", "#ffffff"].into_iter().enumerate() { + app.session.set_active(i); + app.run("edit.fill", json!({"color": gray})).unwrap(); + } + app.session.set_active(active); + app + } + + #[test] + fn fields_list_the_sources_by_id_and_params_take_their_indices() { + let mut app = app(3); + app.run("file.new", json!({"width": 9, "height": 8, "mode": "grayscale"})).unwrap(); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "rgb"})).unwrap(); + app.session.set_active(2); + let f = fields(&app).unwrap(); + let ids = [id(&app, 2), id(&app, 0), id(&app, 1)]; + assert_eq!(f["__sources"].as_array().unwrap().iter().map(|s| s[0].as_u64().unwrap()).collect::>(), ids, "the active one first"); + assert_eq!((f["mode"].clone(), f["documents"].clone()), (json!("rgb"), json!(ids)), "one each, none repeated"); + assert_eq!(params(&app, &f).unwrap(), json!({"mode": "rgb", "documents": [2, 0, 1]})); + assert!(fields(&PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())).is_none()); + } + + /// Three documents fill RGB and Lab, not CMYK; four fill CMYK too. + #[test] + fn only_the_modes_the_documents_fill_are_offered() { + let f = fields(&app(3)).unwrap(); + assert_eq!(offered(&f), ["rgb", "lab"]); + assert!(can_confirm(&f)); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert_eq!(problem(&cmyk), Some(TOO_FEW), "CMYK from three documents (as automation could ask) greys OK"); + let app = app(4); + let f = fields(&app).unwrap(); + assert_eq!(offered(&f), ["rgb", "cmyk", "lab"]); + let mut cmyk = f.clone(); + cmyk.insert("mode".into(), json!("cmyk")); + assert!(can_confirm(&cmyk)); + assert_eq!(params(&app, &cmyk).unwrap()["documents"], json!([3, 0, 1, 2]), "the active (last opened) one first"); + } + + /// A document picked for two channels greys OK with the reason; a refused OK keeps the dialog + /// open and merges nothing. + #[test] + fn repeated_picks_grey_ok_and_keep_the_dialog_open() { + let mut app = app(3); + let (a, b) = (id(&app, 0), id(&app, 1)); + let id = open(&mut app).unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("documents".into(), json!([a, a, b])); + assert_eq!(problem(&d.fields), Some(REPEATED)); + assert!(!can_confirm(&d.fields)); + d.fields.insert("documents".into(), json!([a, b, 999_999])); + assert_eq!(problem(&d.fields), Some(REPEATED), "a document the dialog did not offer"); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(app.ui.dialogs.iter().any(|d| d.id == id), "the dialog stays open"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(can_confirm(&Map::new()), "other dialogs are not affected"); + } + + /// Documents moved while the dialog is open: OK still merges the ones picked, by id, into the + /// channels picked for them. + #[test] + fn ok_merges_the_picked_documents_after_a_reorder() { + let mut app = shaded(0); + let (black, gray, white) = (id(&app, 0), id(&app, 1), id(&app, 2)); + let dialog = open(&mut app).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!([black, white, gray])); + // A script reorders the tabs while the dialog is open: white first, black last. + app.session.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); + app.session.execute("document.move", json!({"document": 1, "to": 2})).unwrap(); + assert_eq!([id(&app, 0), id(&app, 1), id(&app, 2)], [white, gray, black]); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().doc.mode), (1, ColorMode::Rgb)); + assert_eq!(merged_pixel(&app), [0, 255, 128], "red, green and blue from the documents picked, not their old tabs"); + } + + /// A picked document closed while the dialog is open: OK refuses with the reason, keeps the + /// dialog and closes nothing, even when another same-size grayscale document took its tab. + #[test] + fn a_closed_pick_is_refused_and_no_other_document_merges() { + let mut app = shaded(0); + app.run("file.new", json!({"width": 8, "height": 8, "mode": "grayscale"})).unwrap(); + app.session.set_active(0); + let spare = id(&app, 3); + let dialog = open(&mut app).unwrap(); + let picked: Vec = (0..3).map(|i| id(&app, i)).collect(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("documents".into(), json!(picked)); + app.session.execute("file.close", json!({"document": 1})).unwrap(); + assert_eq!(id(&app, 2), spare, "the spare document is where a picked one was"); + let why = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert_eq!(why, CLOSED); + let d = app.ui.dialogs.iter().find(|d| d.id == dialog).expect("the dialog stays open"); + assert_eq!(d.fields["__error"], json!(CLOSED), "with the reason under the pickers"); + assert_eq!(app.session.documents().len(), 3, "nothing merged or closed"); + assert!(app.session.documents().iter().any(|d| d.doc.id.0 == spare)); + } + + /// A picked document turned into another mode while the dialog is open is refused too; should + /// the engine refuse a merge after OK, the dialog comes back with the reason. + #[test] + fn a_changed_pick_or_a_refused_merge_keeps_the_dialog() { + let mut app = app(3); + let dialog = open(&mut app).unwrap(); + let f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + app.run("image.mode.rgb", json!({})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app, dialog).unwrap_err(), CHANGED); + assert!(app.ui.dialogs.iter().any(|d| d.id == dialog)); + app.ui.dialogs.clear(); + assert!(confirm(&mut app, &f).is_err()); + let again = &app.ui.dialogs.last().expect("the dialog again").fields; + assert!(owns(again) && again["__error"].is_string()); + assert_eq!(app.session.documents().len(), 3); + } + + #[test] + fn body_renders_every_mode_and_the_reason() { + let app = app(4); + let ids: Vec = (0..4).map(|i| id(&app, i)).collect(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (mode, docs, error) in + [("rgb", json!(ids[..3]), None), ("cmyk", json!([ids[0], ids[1], ids[2], ids[2]]), None), ("lab", json!(ids[1..]), Some(CLOSED))] + { + let mut f = fields(&app).unwrap(); + f.insert("mode".into(), json!(mode)); + f.insert("documents".into(), docs); + if let Some(e) = error { + f.insert("__error".into(), json!(e)); + } + ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear(); + assert!(owns(&f)); + } + } +} diff --git a/crates/ui-egui/src/dialogs/sizing/tests.rs b/crates/ui-egui/src/dialogs/sizing/tests.rs index a7e7c1dd20..7c26de6c81 100644 --- a/crates/ui-egui/src/dialogs/sizing/tests.rs +++ b/crates/ui-egui/src/dialogs/sizing/tests.rs @@ -50,6 +50,30 @@ fn dialog_maps_to_engine_command() { assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50)); } +/// #9260: OK with a new Width resizes a document whose own resolution is outside the range +/// the command accepts (an imported PNG's pHYs of 1 px/m is 0.0254 ppi); the dialog sends that +/// resolution back unchanged. +#[test] +fn ok_resizes_a_document_with_an_imported_resolution_out_of_range() { + for own in [0.0254_f32, 40_000.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap(); + app.session + .edit("ppi", |doc, _| { + doc.resolution_dpi = own; + Ok(()) + }) + .unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("width".into(), json!(100)); + d.fields.insert("height".into(), json!(50)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(100, 50), own), "{own}"); + } +} + #[test] fn alt_reset_restores_open_dimensions() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/snapshot_dialog.rs b/crates/ui-egui/src/dialogs/snapshot_dialog.rs index 052b9b4f84..04c0430dc6 100644 --- a/crates/ui-egui/src/dialogs/snapshot_dialog.rs +++ b/crates/ui-egui/src/dialogs/snapshot_dialog.rs @@ -17,7 +17,12 @@ pub fn owns(fields: &Map) -> bool { } /// Open the dialog on the active document, suggesting the name the camera button would give. +/// Not while a Free Transform is open: it owns the document until committed or cancelled, so the +/// camera button does nothing then either (#6087). pub fn open(app: &mut PhotocraftApp) -> Option { + if app.ui.transform.is_some() { + return None; + } let name = photocraft_engine::history_cmds::next_snapshot_name(app.session.active()?); let mut f = Map::new(); f.insert(MARK.into(), json!(true)); @@ -28,8 +33,17 @@ pub fn open(app: &mut PhotocraftApp) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } +/// Takes the snapshot. A cleared Name gets the next default name, as the camera button would give +/// (#6087); agents calling the command with an empty name still get an error. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - app.run(COMMAND, json!({"name": f.get("name"), "from": f.get("from")})) + let mut p = Map::new(); + if let Some(name) = f.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) { + p.insert("name".into(), json!(name)); + } + if let Some(from) = f.get("from") { + p.insert("from".into(), from.clone()); + } + app.run(COMMAND, Value::Object(p)) } fn label(ui: &mut egui::Ui, text: &str) { @@ -47,11 +61,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // Opens on the name, selected, so typing replaces it (Photoshop). if !ui.is_sizing_pass() && !f.contains_key("__named") { r.request_focus(); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), r.id); - } + crate::field_tab::select_all(ui.ctx(), r.id, &name); f.insert("__named".into(), json!(true)); } f.insert("name".into(), json!(name)); @@ -69,3 +79,47 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::PhotocraftApp; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app + } + + /// An open Free Transform owns the document: New Snapshot does not open under it, whoever + /// asks (#6087). + #[test] + fn does_not_open_during_free_transform() { + let mut app = app(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + assert_eq!(super::open(&mut app), None); + assert!(app.ui.dialogs.is_empty()); + crate::transform_tool::cancel(&mut app); + assert!(super::open(&mut app).is_some()); + } + + /// A cleared Name takes the default name on OK, as the camera button would (#6087); a typed + /// one is trimmed. The command itself still refuses an empty name. + #[test] + fn a_cleared_name_gets_the_default_name() { + let mut app = app(); + for (typed, want) in [("", "Snapshot 1"), (" ", "Snapshot 2"), (" Mine ", "Mine")] { + let id = super::open(&mut app).unwrap(); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap(); + d.fields.insert("name".into(), json!(typed)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.active().unwrap().snapshots.last().unwrap().name, want); + } + assert!(app.run(super::COMMAND, json!({"name": ""})).is_err(), "agents still get the error"); + } +} diff --git a/crates/ui-egui/src/export_quality_tests.rs b/crates/ui-egui/src/export_quality_tests.rs new file mode 100644 index 0000000000..1f1bfd88ae --- /dev/null +++ b/crates/ui-egui/src/export_quality_tests.rs @@ -0,0 +1,230 @@ +//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG +//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command +//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no +//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and +//! PNG has none. +use crate::PhotocraftApp; +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::{Value, json}; + +const ARTBOARDS: &str = "file.export.artboardsToFiles"; +const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"]; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()) + }); + let app = h.state_mut(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + // A noisy layer inside the board, so the JPEG quality shows in the file. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap(); + app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap(); + app.run("layerComp.new", json!({"name": "Comp"})).unwrap(); + app.sync_views(); + h +} + +fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 { + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("dir".into(), json!(dir)); + fields.insert("prefix".into(), json!("out")); + fields.insert("format".into(), json!(format)); + h.run_steps(4); + dialog +} + +fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value { + crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields) +} + +fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec { + let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// The same export run straight through the command, into `dir`. +fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option) -> Vec { + let mut p = json!({"dir": dir, "prefix": "out", "format": format}); + if let Some(q) = quality { + p["quality"] = json!(q); + } + let r = h.state_mut().run(ARTBOARDS, p).unwrap(); + std::fs::read(r["files"][0].as_str().unwrap()).unwrap() +} + +/// Whether the Quality number field (not its row label) takes input. +fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool { + let fields: Vec = + h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect(); + assert!(!fields.is_empty(), "the dialog shows a Quality field"); + assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}"); + fields[0] +} + +/// Whether the Custom checkbox is ticked. +fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool { + h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +/// The value the Quality field shows. +fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option { + h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value() +} + +fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) { + h.get_by_role_and_label(Role::CheckBox, "Custom").click(); + h.run_steps(2); +} + +/// Types `text` into the Quality field and commits it with Tab, as a user does. +fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) { + h.get_by_role_and_label(Role::SpinButton, "Quality").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for ch in text.chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + h.key_press(egui::Key::Tab); + h.run_steps(2); +} + +/// Holds Alt, which turns Cancel into Reset, and clicks it. +fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) { + let alt = egui::Modifiers::ALT; + h.event(egui::Event::ModifiersChanged(alt)); + h.run_steps(2); + let at = h.get_by_label("Reset").rect().center(); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); +} + +fn tmp() -> (tempfile::TempDir, String) { + let t = tempfile::tempdir().unwrap(); + let s = t.path().to_string_lossy().into_owned(); + (t, s) +} + +#[test] +fn artboards_to_files_sends_a_custom_jpeg_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let mut sizes = Vec::new(); + for quality in [0, 3, 12] { + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out"); + tick_custom(&mut h); + assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10"); + type_quality(&mut h, &quality.to_string()); + assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality"); + assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality"); + sizes.push(bytes.len()); + } + assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}"); +} + +#[test] +fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() { + let mut h = harness(); + let (_a, via_dialog) = tmp(); + let (_b, via_command) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent"); + let bytes = exported(&mut h, dialog); + assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality"); +} + +#[test] +fn export_to_files_quality_leaves_png_and_webp_as_they_were() { + let mut h = harness(); + let (_a, dir) = tmp(); + for id in EXPORTS { + for format in ["png", "webp"] { + let dialog = open(&mut h, id, &dir, format); + // Custom on from an earlier JPEG choice still sends nothing for these formats. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out"); + assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent"); + let bytes = exported(&mut h, dialog); + if format == "webp" { + // The default WebP stays lossless (a VP8L bitstream, not lossy VP8). + assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless"); + } + } + // Back to JPEG with Custom on, the field sends its quality again. + let dialog = open(&mut h, id, &dir, "jpg"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true)); + h.run_steps(2); + assert!(quality_enabled(&h), "{id}"); + assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}"); + crate::dialogs::cancel(h.state_mut(), dialog).unwrap(); + } +} + +/// A Custom quality survives a switch to another format and back in the same dialog. +#[test] +fn the_custom_quality_survives_a_format_round_trip() { + let mut h = harness(); + let (_a, dir) = tmp(); + let dialog = open(&mut h, ARTBOARDS, &dir, "jpg"); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + for format in ["png", "webp"] { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format)); + h.run_steps(2); + assert!(!quality_enabled(&h), "{format}: the field is greyed out"); + assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on"); + assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent"); + } +} + +/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to +/// how the dialog opened, and keeps the dialog open. +#[test] +fn alt_reset_restores_the_custom_quality() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap(); + // JPEG chosen in the dialog, after it opened: Reset puts the format back too. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(4); + tick_custom(&mut h); + type_quality(&mut h, "4"); + assert_eq!(params(&mut h, dialog)["quality"], json!(4)); + alt_reset(&mut h); + assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open"); + let p = params(&mut h, dialog); + assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality"); + assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again"); + assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality"); + // Choosing JPEG again still sends no quality until Custom is ticked. + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg")); + h.run_steps(2); + assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off"); + assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent"); +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e0..abebcac5f4 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -322,6 +322,39 @@ fn menu_place_starts_transform_for_each_chosen_image() { } } +/// #10031: File › Place decodes in the background; Free Transform starts when the layer lands, and +/// a second place is refused while the first runs. +#[test] +fn menu_place_runs_in_the_background_and_starts_transform_when_it_lands() { + let (mut app, open, _) = app(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + app.background_jobs = true; + let ctx = egui::Context::default(); + let layers = app.session.active().unwrap().doc.layer_count(); + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("drawing.png".into(), bytes.clone()))); + app.poll_file_dialog(&ctx, None); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "drawing.png"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + assert_eq!(app.session.active().unwrap().doc.layer_count(), layers); + // Another place is refused while this one runs, and the first one stays tracked. + assert!(crate::jobs_ui::start_place_bytes(&mut app, "other.png", bytes, None, &serde_json::json!({})).is_err()); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + let t = std::time::Instant::now(); + while app.jobs.place.is_some() { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "the place did not finish"); + crate::jobs_ui::tick(&mut app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer_count(), layers + 1); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "drawing"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + #[test] fn cancelled_or_invalid_menu_place_does_not_start_transform() { for command in ["file.placeEmbedded", "file.placeLinked"] { diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index ed5c3a642e..1534502a4f 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -6,6 +6,7 @@ //! are shown as errors (status bar + notice); import warnings as a notice. use crate::{PhotocraftApp, notices}; +use photocraft_doc::DocId; use photocraft_engine::file_cmds::{is_template, untitled_name}; /// A request from the operating system, delivered by the platform shell through @@ -33,7 +34,8 @@ pub enum DropTarget { Elsewhere, } -/// A file dropped on the canvas, waiting its turn to be placed. +/// A file dropped on the canvas, waiting its turn to be placed in the document it was dropped on +/// (queued with its [`DocId`] in `drop_places`). pub(crate) enum DropPlace { /// Destination captured when the file is dropped, before a later pointer move. At { source: Box, frame: Option }, @@ -44,6 +46,41 @@ pub(crate) enum DropPlace { Bytes(String, Vec), } +impl DropPlace { + /// The file contents held while it waits for its document (a path drop holds none yet). + fn queued_bytes(&self) -> usize { + match self { + Self::At { source, .. } => source.queued_bytes(), + Self::File(_) => 0, + Self::Bytes(_, bytes) => bytes.len(), + } + } + + /// Whether it holds its file's contents while it waits (a browser drop); a path drop is read + /// only when its turn comes. + fn holds_bytes(&self) -> bool { + match self { + Self::At { source, .. } => source.holds_bytes(), + Self::File(_) => false, + Self::Bytes(..) => true, + } + } +} + +/// The most browser drops, each holding its whole file, that can wait for their document to come +/// back in view. Path drops hold only their path and aren't counted: dropping a folder's worth of +/// files on the canvas places them all. +const MAX_QUEUED_DROPS: usize = 32; +/// The most dropped file contents kept in memory while they wait: a browser drop carries its whole +/// file, and anything queued on a document in another tab stays until it is back in view. +const MAX_QUEUED_DROP_BYTES: usize = 256 * 1024 * 1024; + +/// Whether the drop queue takes a browser drop of `incoming` bytes when it holds `queued` of them +/// and `bytes` in all. +fn queue_takes_drop(queued: usize, bytes: usize, incoming: usize) -> bool { + queued < MAX_QUEUED_DROPS && bytes.saturating_add(incoming) <= MAX_QUEUED_DROP_BYTES +} + /// `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. @@ -247,6 +284,11 @@ impl PhotocraftApp { }); } + /// Show "Couldn't place : " as an error (status bar + notice). + pub fn place_failed(&mut self, name: &str, err: &str) { + self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", name), ("error", err)])); + } + /// Log `msg` and show it as an error (status bar + notice). fn failed(&mut self, msg: String) { log::warn!("{msg}"); @@ -272,7 +314,9 @@ impl PhotocraftApp { for f in files { let name = dropped_name(&f); if target == DropTarget::Canvas && !crate::preset_files_ui::is_preset_file(&name) { - self.drop_places.push_back(DropPlace::At { source: Box::new(DropPlace::File(f)), frame }); + if let Some(doc) = self.session.active().map(|st| st.doc.id) { + self.queue_drop(doc, DropPlace::At { source: Box::new(DropPlace::File(f)), frame }); + } continue; } match self.open_dropped_file(&f, &name, slot) { @@ -295,8 +339,12 @@ impl PhotocraftApp { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); - if places.contains(&true) && self.ui.transform.is_some() { - crate::transform_tool::commit(self); + if places.contains(&true) { + // The shell reads the files after this returns: they go to this document even if the + // tab changes before they arrive. + if self.ui.transform.is_some() { + crate::transform_tool::commit(self); + } } places } @@ -337,9 +385,27 @@ impl PhotocraftApp { /// Place the next file dropped on the canvas and start Free Transform on it, once no other /// transform is in progress (so several files are placed and transformed one after another). + /// With background jobs on, a file is placed by a job: the next one waits until that job has + /// landed and its Free Transform has been applied (see `jobs_ui::finish_place`). + /// + /// Each file goes into the document it was dropped on, never into whichever one is active now: + /// files dropped on a document in another tab wait until it is back in view, and are discarded + /// (with a message) when it is closed. pub(crate) fn place_next_dropped(&mut self, ctx: &egui::Context) { - while self.ui.transform.is_none() { - let Some(next) = self.drop_places.pop_front() else { return }; + // Leave the closed-document notice up for this frame: starting the next queued placement + // would clear the status bar. The next frame retries the queue, so ask for one at once: + // another document's files would otherwise wait for the next input event. + if self.discard_drops_of_closed_documents() { + if !self.drop_places.is_empty() { + ctx.request_repaint(); + } + return; + } + while self.ui.transform.is_none() && self.jobs.place.is_none() && self.session.active_job().is_none() { + let Some(active) = self.session.active().map(|st| st.doc.id) else { return }; + let Some((_, next)) = self.drop_places.iter().position(|(doc, _)| *doc == active).and_then(|i| self.drop_places.remove(i)) else { + return; + }; let (next, frame) = match next { DropPlace::At { source, frame } => (*source, Some(frame)), next => (next, None), @@ -372,6 +438,8 @@ impl PhotocraftApp { } }; match placed { + // Running in the background: Free Transform starts when the layer has landed. + Ok(started) if crate::jobs_ui::track_place(self, &name, &started) => return, Ok(result) => { if result.get("frame").and_then(serde_json::Value::as_u64).is_some() { continue; @@ -380,18 +448,66 @@ impl PhotocraftApp { self.ui.status = e; } } - Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &name), ("error", &e)])), + Err(e) => self.place_failed(&name, &e), } } } + /// Queue the web drops onto the canvas that the shell has read since the last frame + /// ([`Services::place_inbox`](crate::Services::place_inbox)), for the document they were + /// dropped on. + pub(crate) fn queue_web_places(&mut self) { + let arrived: Vec<(DocId, String, Vec)> = + self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (doc, name, bytes) in arrived { + self.queue_drop(doc, DropPlace::Bytes(name, bytes)); + } + } + + /// Queue a canvas drop for the document it was dropped on, keeping the file contents it holds + /// bounded: files dropped on a document in another tab wait here, and a browser drop carries + /// its whole file. A path drop holds no contents, so any number of them queue. + fn queue_drop(&mut self, doc: DocId, place: DropPlace) { + let held = self.drop_places.iter().filter(|(_, p)| p.holds_bytes()).count(); + let bytes = self.drop_places.iter().fold(0usize, |sum, (_, p)| sum.saturating_add(p.queued_bytes())); + if place.holds_bytes() && !queue_takes_drop(held, bytes, place.queued_bytes()) { + self.ui.status = tl!("Dropped files weren't placed: too many are waiting for their document.").into(); + self.ui.status_error = true; + return; + } + self.drop_places.push_back((doc, place)); + } + + /// Drop the queued files whose document has been closed, saying so. True when one was dropped + /// (the caller lets the notice stand). + fn discard_drops_of_closed_documents(&mut self) -> bool { + if self.drop_places.is_empty() { + return false; + } + let before = self.drop_places.len(); + let open: Vec = self.session.documents().iter().map(|st| st.doc.id).collect(); + self.drop_places.retain(|(doc, _)| open.contains(doc)); + let discarded = self.drop_places.len() < before; + if discarded { + self.ui.status = tl!("Dropped files weren't placed: their document was closed.").into(); + self.ui.status_error = false; + } + discarded + } + /// Place a file's bytes as a smart object layer (File › Place; `linked`: Place Linked, to that - /// path), for files that weren't read from a path the command can read. + /// path), for files that weren't read from a path the command can read. With background jobs + /// on it starts a job (`{"job", "pending": true}`; see [`crate::jobs_ui::track_place`]). pub(crate) fn place_bytes(&mut self, name: &str, bytes: Vec, linked: Option) -> Result { self.place_bytes_with(name, bytes, linked, &serde_json::json!({})) } + fn place_bytes_with(&mut self, name: &str, bytes: Vec, linked: Option, params: &serde_json::Value) -> Result { - let r = photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, params).map_err(|e| e.to_string()); + let r = if self.background_jobs { + crate::jobs_ui::start_place_bytes(self, name, bytes, linked, params) + } else { + photocraft_engine::file_cmds::place_bytes(&mut self.session, name, bytes, linked, params).map_err(|e| e.to_string()) + }; self.sync_views(); r } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index bd74eceb9e..037cdda108 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -189,8 +189,9 @@ fn web_drops_onto_the_canvas_are_placed_from_their_bytes() { at.set(egui::pos2(20.0, 20.0)); assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false], "off the canvas: opened"); let layers = layer_count(&app); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3))); - app.drop_places.push_back(crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec())); + let doc = app.session.active().unwrap().doc.id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec()))); app.place_next_dropped(&ctx); assert_eq!(layer_count(&app), layers + 1); assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -538,6 +539,366 @@ fn a_separate_drop_applies_the_open_transform_and_places_at_once() { assert_eq!(history_steps(&app), steps + 2); } +/// Apply the jobs that have finished, frame by frame, until `done` holds (or 30 s pass). +fn tick_until(app: &mut PhotocraftApp, ctx: &egui::Context, done: impl Fn(&PhotocraftApp) -> bool) { + let t = std::time::Instant::now(); + while !done(app) { + assert!(t.elapsed() < std::time::Duration::from_secs(30), "not done after 30 s"); + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// #10031: with background jobs a dropped file is decoded on a worker. Free Transform starts when +/// the layer has landed, and the next file is started only after that transform is applied. +#[test] +fn background_drops_are_placed_by_a_job_one_after_another() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("the first file is being placed"); + assert_eq!(place.name, "first.png"); + assert!(app.session.job(place.job).is_some(), "as a background job"); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps), "nothing lands before the job is applied"); + assert!(app.ui.transform.is_none(), "Free Transform waits for the layer"); + // Frames while it runs don't start the second file next to it. + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.jobs.place.as_ref().map(|p| p.job), Some(place.job)); + + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "first"); + // The second file waits for the first transform. + app.place_next_dropped(&ctx); + assert!(app.jobs.place.is_none() && app.drop_places.len() == 1); + crate::transform_tool::commit(&mut app); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("second.png")); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + crate::transform_tool::commit(&mut app); + assert_eq!((layer_count(&app), history_steps(&app)), (layers + 2, steps + 2)); + assert!(app.drop_places.is_empty()); +} + +/// A file that can't be decoded is reported by name and the files dropped after it go on. +#[test] +fn a_background_place_that_fails_reports_it_and_the_queue_continues() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("bad.png", Ok(b"bad".to_vec())), dropped("good.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("bad.png")); + tick_until(&mut app, &ctx, |app| app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png"))); + assert_eq!(layer_count(&app), layers, "nothing was placed"); + assert_eq!(app.jobs.place.as_ref().map(|p| p.name.as_str()), Some("good.png"), "the next file started at once"); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(app.ui.notices.iter().filter(|n| n.error).count(), 1); +} + +/// Cancelling the place says so, places nothing and goes on with the queue. +#[test] +fn cancelling_a_background_place_continues_the_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let (layers, steps) = (layer_count(&app), history_steps(&app)); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + let first = app.jobs.place.clone().expect("being placed"); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + crate::jobs_ui::tick(&mut app, &ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (layers, steps)); + let second = app.jobs.place.clone().expect("the queue went on"); + assert_eq!(second.name, "second.png"); + assert_ne!(second.job, first.job); + assert!(!app.ui.status_error); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); + assert_eq!(layer_count(&app), layers + 1); +} + +/// Frames with nothing to wait for: jobs applied and the drop queue served. +fn idle_frames(app: &mut PhotocraftApp, ctx: &egui::Context) { + for _ in 0..3 { + crate::jobs_ui::tick(app, ctx); + app.place_next_dropped(ctx); + } +} + +/// Drops two files on the canvas of document A (the app's only one) with background jobs on, starts +/// placing the first, then opens document B, which becomes active. Returns A's id and layer count. +fn two_drops_then_switch(app: &mut PhotocraftApp, ctx: &egui::Context) -> (photocraft_doc::DocId, usize) { + app.background_jobs = true; + let (a, layers) = (app.session.active().unwrap().doc.id, layer_count(app)); + app.open_dropped(ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("first.png", a))); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id, a); + (a, layers) +} + +/// FredApps' review of #10147: files dropped on A stay A's when the tab changes while the first is +/// placed. It lands in A, no Free Transform opens over B, the second waits rather than going into B, +/// and goes on in A when A is back in view. +#[test] +fn background_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers + 1, "the first landed in A"); + assert_eq!(app.drop_places.len(), 1, "the second waits for A"); + + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 2); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); + assert_eq!(app.session.documents()[1].doc.layers.len(), b_layers); +} + +/// Cancelling A's place while B is in view doesn't hand A's next file to B. +#[test] +fn cancelling_a_background_place_from_another_tab_keeps_the_queue_in_its_document() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let (a, layers) = two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + let first = app.jobs.place.clone().unwrap(); + assert!(crate::jobs_ui::cancel(&mut app, first.job)); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.session.documents()[0].doc.layers.len(), layers, "the cancelled file wasn't placed"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.place_next_dropped(&ctx); + assert_eq!(app.jobs.place.as_ref().map(|p| (p.name.as_str(), p.doc)), Some(("second.png", a))); +} + +/// Closing A drops its waiting files with a message; none goes into B. +#[test] +fn closing_the_document_discards_its_dropped_files() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + app.run("file.close", json!({"document": 0})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.drop_places.is_empty()); + assert!(app.ui.status.contains("closed") && !app.ui.status_error, "{}", app.ui.status); + idle_frames(&mut app, &ctx); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// The closed-document notice survives the same call that would start the next queued file: the +/// placement waits one frame, so the message is drawn. That frame is asked for at once, so B's file +/// doesn't wait for the next input event. +#[test] +fn the_closed_document_notice_stays_when_the_next_queued_file_starts() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + app.drop_places.push_back((a, crate::file_open::DropPlace::Bytes("a.png".into(), png_bytes(4, 3)))); + app.drop_places.push_back((b, crate::file_open::DropPlace::Bytes("b.png".into(), png_bytes(4, 3)))); + app.run("file.close", json!({"document": 0})).unwrap(); + for _ in 0..4 { + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + assert!(!ctx.has_requested_repaint(), "idle"); + app.place_next_dropped(&ctx); + assert!(app.ui.status.contains("closed"), "{}", app.ui.status); + assert_eq!(app.drop_places.len(), 1, "B's file waits for the next frame"); + assert!(ctx.has_requested_repaint(), "and asks for it"); + assert!(app.jobs.place.is_none() && app.ui.transform.is_none()); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// The drop queue is bounded: files dropped on another tab wait, and a browser drop holds its whole +/// file, so neither a flood of drops nor a huge one grows it without limit. +#[test] +fn the_drop_queue_is_bounded() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + for _ in 0..crate::file_open::MAX_QUEUED_DROPS { + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("p.png".into(), vec![0])); + } + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("over.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), crate::file_open::MAX_QUEUED_DROPS, "the queue keeps its cap"); + assert!(app.ui.status.contains("too many") && app.ui.status_error, "{}", app.ui.status); + assert!(crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 0)); + assert!(!crate::file_open::queue_takes_drop(0, crate::file_open::MAX_QUEUED_DROP_BYTES, 1)); + assert!(!crate::file_open::queue_takes_drop(crate::file_open::MAX_QUEUED_DROPS, 0, 0)); +} + +/// Path drops hold no file contents while they wait, so the cap on browser drops doesn't apply to +/// them: dropping 40 files on the canvas queues all of them, and a browser drop after them still fits. +#[test] +fn many_path_drops_all_queue() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + // More than the browser drops' cap; none is read while it waits. + let n = crate::file_open::MAX_QUEUED_DROPS + 8; + app.open_dropped(&ctx, (0..n).map(|i| dropped(format!("p{i}.png"), Ok(Vec::new()))).collect(), Some(egui::pos2(400.0, 300.0))); + assert_eq!(app.drop_places.len(), n); + assert!(!app.ui.status_error, "{}", app.ui.status); + app.queue_drop(doc, crate::file_open::DropPlace::Bytes("web.png".into(), vec![0])); + assert_eq!(app.drop_places.len(), n + 1, "path drops don't use up the browser drops' room"); +} + +/// Closing A while its file is being placed: nothing lands anywhere, and A's next file is dropped. +#[test] +fn closing_the_document_while_its_file_is_placed_places_nothing_elsewhere() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + two_drops_then_switch(&mut app, &ctx); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.run("file.close", json!({"document": 0})).unwrap(); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + idle_frames(&mut app, &ctx); + assert!(app.drop_places.is_empty() && app.jobs.place.is_none() && app.ui.transform.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps)); +} + +/// Without background jobs too: leaving A's Free Transform for B cancels that place (#1099) and A's +/// next file waits for A instead of going into B. +#[test] +fn inline_drops_stay_with_their_document_when_the_tab_changes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + app.open_dropped(&ctx, vec![dropped("first.png", Ok(png_bytes(4, 4))), dropped("second.png", Ok(png_bytes(2, 2)))], Some(egui::pos2(400.0, 300.0))); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_some()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + app.place_next_dropped(&ctx); + assert!(app.ui.transform.is_none()); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert_eq!(app.drop_places.len(), 1); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert_eq!(layer_count(&app), layers + 1, "the first was cancelled by leaving it, the second placed"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "second"); +} + +/// A web drop goes to the document it was dropped on, even when the tab changes before the shell +/// has read the files. +#[test] +fn web_drops_go_to_the_document_they_were_dropped_on() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + app.services.cursor_pos = Some(Box::new(|_: &egui::Context| Some(egui::pos2(400.0, 300.0)))); + assert_eq!(app.dropped_on_canvas(&ctx, &["photo.png".to_string()]), [true]); + let target = app.session.active().map(|st| st.doc.id).unwrap(); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let (b_layers, b_steps) = (layer_count(&app), history_steps(&app)); + inbox.lock().unwrap().push((target, "photo.png".into(), png_bytes(4, 3))); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!((layer_count(&app), history_steps(&app)), (b_layers, b_steps), "nothing placed in B"); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); +} + +#[test] +fn overlapping_web_drop_deliveries_keep_each_target() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let a = app.session.active().unwrap().doc.id; + let a_layers = layer_count(&app); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let b = app.session.active().unwrap().doc.id; + let b_layers = layer_count(&app); + let inbox: crate::PlaceInbox = Default::default(); + app.services.place_inbox = Some(inbox.clone()); + // B's read finishes before A's, although A was dropped first. + inbox.lock().unwrap().extend([(b, "b.png".into(), png_bytes(3, 2)), (a, "a.png".into(), png_bytes(4, 3))]); + app.queue_web_places(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), b_layers + 1); + crate::transform_tool::commit(&mut app); + assert!(app.session.set_active(0)); + app.sync_views(); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), a_layers + 1); + assert!(app.drop_places.is_empty()); +} + +/// A file dropped from a path is placed by the `file.placeEmbedded` job, read on its worker, and a +/// drop that arrives meanwhile queues behind it. +#[test] +fn a_background_drop_from_a_path_is_tracked_and_later_drops_queue() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-place-job-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let abs = dir.join("photo.png"); + std::fs::write(&abs, png_bytes(40, 20)).unwrap(); + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + app.background_jobs = true; + let layers = layer_count(&app); + let at = Some(egui::pos2(400.0, 300.0)); + app.open_dropped(&ctx, vec![dropped(abs, Err("egui's reader must not be used".into()))], at); + app.place_next_dropped(&ctx); + let place = app.jobs.place.clone().expect("being placed"); + assert_eq!(place.name, "photo.png"); + assert_eq!(app.jobs.last_started, Some(place.job)); + // A drop while it runs is queued, not started beside it. + app.open_dropped(&ctx, vec![dropped("later.png", Ok(png_bytes(2, 2)))], at); + app.place_next_dropped(&ctx); + assert_eq!(app.drop_places.len(), 1); + assert_eq!(app.session.jobs().len(), 1); + tick_until(&mut app, &ctx, |app| app.jobs.place.is_none()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.drop_places.len(), 1, "waiting for the transform"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1099: switching tabs after an actual canvas drop cancels the place in its source document. #[test] fn switching_documents_after_a_canvas_drop_undoes_only_the_place() { @@ -694,6 +1055,12 @@ fn drag_and_drop_in_the_running_app() { pointer.set(h.state().drop_canvas_rect.expect("the canvas is shown").center()); h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); h.run_steps(2); + // Placement runs on a worker; frame count does not imply completion. + let started = std::time::Instant::now(); + while h.state().jobs.place.is_some() && started.elapsed() < std::time::Duration::from_secs(30) { + h.step(); + } + assert!(h.state().jobs.place.is_none(), "placement completed"); assert_eq!(h.state().session.documents().len(), 3, "no new document"); assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers + 1); assert_eq!(h.state().ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); @@ -1127,3 +1494,21 @@ fn opening_an_open_file_again_shows_its_tab() { let _ = std::fs::remove_dir_all(&d); } } + +#[test] +fn queued_drop_waits_for_an_external_document_job() { + let mut app = app_with_canvas(); + let doc = app.session.active().unwrap().doc.id; + app.session.start_job("test.external", json!({}), "External job", true, |_| Ok(()), |_, ()| Ok(json!(null))).unwrap(); + let job = app.session.active_job().unwrap().id; + app.drop_places.push_back((doc, crate::file_open::DropPlace::Bytes("queued.png".into(), png_bytes(4, 3)))); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(app.drop_places.len(), 1, "busy document retains its drop"); + assert!(app.jobs.place.is_none()); + let layers = layer_count(&app); + app.session.wait_job(job).unwrap(); + app.place_next_dropped(&egui::Context::default()); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(app.drop_places.is_empty()); +} diff --git a/crates/ui-egui/src/gpu_memo.rs b/crates/ui-egui/src/gpu_memo.rs new file mode 100644 index 0000000000..710fec97fa --- /dev/null +++ b/crates/ui-egui/src/gpu_memo.rs @@ -0,0 +1,178 @@ +//! Persistence model for deterministic GPU compositor build failures. +//! +//! Call [`GpuMemo::remember`] only for deterministic build failures. Transient device or +//! allocation errors are deliberately not classified here and are never saved automatically. + +use serde::{Deserialize, Serialize}; + +/// Current on-disk schema version. +pub const GPU_MEMO_VERSION: u32 = 1; +/// Maximum accepted serialized document size. +pub const MAX_GPU_MEMO_BYTES: usize = 64 * 1024; +/// Number of failure records retained, newest last. +pub const MAX_GPU_MEMO_ENTRIES: usize = 8; + +/// Identifies the application, shader set, and graphics stack that produced a failure. +#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct MemoKey { + pub app_version: String, + pub shader_fingerprint: u64, + pub adapter_name: String, + pub vendor: u32, + pub device: u32, + pub driver: String, + pub driver_info: String, + pub backend: String, + pub shader_compiler: String, +} + +/// One remembered deterministic build failure. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MemoEntry { + pub key: MemoKey, + pub error: String, + /// RFC3339 timestamp supplied by the caller. + pub when: String, +} + +/// Bounded, versioned compositor failure memo. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct GpuMemo { + version: u32, + failures: Vec, +} + +impl Default for GpuMemo { + fn default() -> Self { + Self::new() + } +} + +impl GpuMemo { + /// Creates an empty memo using the current schema version. + pub fn new() -> Self { + Self { version: GPU_MEMO_VERSION, failures: Vec::new() } + } + + /// Parse persisted JSON. Rejects oversized, malformed, and unknown-version data. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + let mut memo: Self = serde_json::from_str(text).map_err(|e| e.to_string())?; + if memo.version != GPU_MEMO_VERSION { + return Err(format!("unsupported GPU memo version {}", memo.version)); + } + if memo.failures.len() > MAX_GPU_MEMO_ENTRIES { + let excess = memo.failures.len() - MAX_GPU_MEMO_ENTRIES; + memo.failures.drain(..excess); + } + Ok(memo) + } + + /// Serialize this memo as JSON. + pub fn serialize(&self) -> Result { + let text = serde_json::to_string(self).map_err(|e| e.to_string())?; + if text.len() > MAX_GPU_MEMO_BYTES { + return Err("GPU memo exceeds 64 KiB".into()); + } + Ok(text) + } + + /// Returns the remembered failure for `key`, if present. + pub fn get(&self, key: &MemoKey) -> Option<&MemoEntry> { + self.failures.iter().rev().find(|entry| &entry.key == key) + } + + /// Records a deterministic failure. Callers must exclude transient device-loss and OOM errors. + /// Existing entries for the key are replaced and the oldest record is dropped at capacity. + pub fn remember(&mut self, key: MemoKey, error: String, when: String) { + if self.version != GPU_MEMO_VERSION { + *self = Self::new(); + } + self.remove(&key); + if self.failures.len() == MAX_GPU_MEMO_ENTRIES { + self.failures.remove(0); + } + self.failures.push(MemoEntry { key, error, when }); + } + + /// Removes a memoized failure for `key`; returns whether one existed. + pub fn remove(&mut self, key: &MemoKey) -> bool { + let before = self.failures.len(); + self.failures.retain(|entry| &entry.key != key); + before != self.failures.len() + } + + /// Number of retained failure records. + pub fn len(&self) -> usize { + self.failures.len() + } + + /// Whether the memo has no records. + pub fn is_empty(&self) -> bool { + self.failures.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(n: usize) -> MemoKey { + MemoKey { app_version: n.to_string(), ..MemoKey::default() } + } + + #[test] + fn round_trip_and_remove() { + let mut memo = GpuMemo::new(); + memo.remember(key(1), "compiler failed".into(), "2026-10-28T00:00:00Z".into()); + let parsed = GpuMemo::parse(&memo.serialize().expect("serializes")).expect("parses"); + assert_eq!(parsed.get(&key(1)).map(|entry| entry.error.as_str()), Some("compiler failed")); + let mut parsed = parsed; + assert!(parsed.remove(&key(1))); + assert!(!parsed.remove(&key(1))); + assert!(parsed.is_empty()); + } + + #[test] + fn changing_any_identity_field_invalidates_a_failure() { + let original = MemoKey::default(); + let mut memo = GpuMemo::new(); + memo.remember(original.clone(), "compiler failed".into(), "now".into()); + let variants = [ + MemoKey { app_version: "new".into(), ..original.clone() }, + MemoKey { shader_fingerprint: 1, ..original.clone() }, + MemoKey { adapter_name: "new".into(), ..original.clone() }, + MemoKey { vendor: 1, ..original.clone() }, + MemoKey { device: 1, ..original.clone() }, + MemoKey { driver: "new".into(), ..original.clone() }, + MemoKey { driver_info: "new".into(), ..original.clone() }, + MemoKey { backend: "new".into(), ..original.clone() }, + MemoKey { shader_compiler: "new".into(), ..original.clone() }, + ]; + for variant in variants { + assert!(memo.get(&variant).is_none()); + } + } + + #[test] + fn retains_only_eight_newest_entries() { + let mut memo = GpuMemo::new(); + for n in 0..10 { + memo.remember(key(n), "failure".into(), "now".into()); + } + assert_eq!(memo.len(), MAX_GPU_MEMO_ENTRIES); + assert!(memo.get(&key(0)).is_none()); + assert!(memo.get(&key(1)).is_none()); + assert!(memo.get(&key(2)).is_some()); + assert!(memo.get(&key(9)).is_some()); + } + + #[test] + fn rejects_corrupt_oversized_and_unknown_versions() { + assert!(GpuMemo::parse("{").is_err()); + assert!(GpuMemo::parse(&" ".repeat(MAX_GPU_MEMO_BYTES + 1)).is_err()); + assert!(GpuMemo::parse(r#"{"version":2,"failures":[]}"#).is_err()); + } +} diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index ca832645b7..6c5eee78ad 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -34,6 +34,7 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { if fault.is_out_of_memory() && app.gpu_oom_retries < OOM_AUTO_RETRIES && can_retry_now(app) { app.gpu_oom_retries += 1; if retry_now(app) { + app.retry_compositor(ctx); log::warn!("{fault}; rebuilt the GPU canvas (automatic retry {} of {OOM_AUTO_RETRIES})", app.gpu_oom_retries); app.ui.gpu_fallback_notice = None; } @@ -292,10 +293,12 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); - if let Some(retry) = choice - && let Err(error) = choose_recovery(app, retry) - { - crate::notices::error(app, error); + if let Some(retry) = choice { + match choose_recovery(app, retry) { + Ok(()) if retry => app.retry_compositor(ctx), + Ok(()) => (), + Err(error) => crate::notices::error(app, error), + } } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 69ee8346ab..9703ae5b3d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3564,3 +3564,18 @@ curves Smooth تنعيم Pause For إيقاف مؤقت لمدة seconds ثانية Create a single history state إنشاء حالة سجل واحدة + +# Channels panel menu (#11451) + New Channel… قناة جديدة… + Duplicate Channel تكرار القناة + Delete Channel حذف القناة + New Spot Channel… قناة لون موضعي جديدة… + Merge Spot Channel دمج قناة اللون الموضعي + Channel Options… خيارات القناة… + Split Channels تقسيم القنوات + Merge Channels… دمج القنوات… + Specify Channels: تحديد القنوات: + Not enough grayscale documents for this mode. لا توجد مستندات كافية بتدرج الرمادي لهذا الوضع. + Pick a different document for each channel. اختر مستندًا مختلفًا لكل قناة. + A picked document is no longer open. أحد المستندات المختارة لم يعد مفتوحًا. + A picked document is no longer a grayscale document of the same size. أحد المستندات المختارة لم يعد مستندًا بتدرج الرمادي بالحجم نفسه. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 5d16b97df2..1381bb150c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4605,3 +4605,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Boja tačke Shapes can't use patterns yet Oblici još ne mogu koristiti uzorke Edit Brush… Uredi četkicu… + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj način. + Pick a different document for each channel. Odaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan odabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan odabrani dokument više nije dokument u nijansama sive iste veličine. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b657be0bf3..bb9d6463f0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4247,6 +4247,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neplatná číselná hodnota. Je vyžadováno celé číslo mezi {min}% a {max}%. Byla vložena předchozí hodnota. Color Aware Podle barev Decontaminate Colors Dekontaminovat barvy @@ -4311,3 +4313,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Nahradit obrázek… The frame is locked Rámeček je zamčený The frame is unavailable Rámeček není dostupný + +# Channels panel menu (#11451) + Channel Options… Možnosti kanálu… + Specify Channels: Určit kanály: + Not enough grayscale documents for this mode. Pro tento režim není dost dokumentů ve stupních šedi. + Pick a different document for each channel. Pro každý kanál vyberte jiný dokument. + A picked document is no longer open. Jeden z vybraných dokumentů už není otevřený. + A picked document is no longer a grayscale document of the same size. Jeden z vybraných dokumentů už není dokument ve stupních šedi stejné velikosti. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index dadc00b8aa..c84caa3fe8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4787,3 +4787,11 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Pause For Pause i seconds sekunder Create a single history state Opret én historiktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalindstillinger… + Specify Channels: Angiv kanaler: + Not enough grayscale documents for this mode. Der er ikke nok dokumenter i gråtoner til denne tilstand. + Pick a different document for each channel. Vælg et andet dokument til hver kanal. + A picked document is no longer open. Et af de valgte dokumenter er ikke længere åbent. + A picked document is no longer a grayscale document of the same size. Et af de valgte dokumenter er ikke længere et dokument i gråtoner i samme størrelse. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ffac13ce5e..1918f49a17 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3529,6 +3529,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ungültige Zahleneingabe. Es ist eine ganze Zahl zwischen {min}% und {max}% erforderlich. Der vorherige Wert wurde eingesetzt. Clear Selection Auswahl löschen Color Aware Farbbezogen @@ -3597,3 +3599,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable Der Rahmen ist nicht verfügbar X X Y Y + +# Channels panel menu (#11451) + New Channel… Neuer Kanal… + Duplicate Channel Kanal duplizieren + Delete Channel Kanal löschen + New Spot Channel… Neuer Schmuckfarbenkanal… + Merge Spot Channel Schmuckfarbenkanal zusammenfügen + Channel Options… Kanaloptionen… + Split Channels Kanäle teilen + Merge Channels… Kanäle zusammenfügen… + Specify Channels: Kanäle angeben: + Not enough grayscale documents for this mode. Nicht genug Graustufen-Dokumente für diesen Modus. + Pick a different document for each channel. Wählen Sie für jeden Kanal ein anderes Dokument. + A picked document is no longer open. Ein ausgewähltes Dokument ist nicht mehr geöffnet. + A picked document is no longer a grayscale document of the same size. Ein ausgewähltes Dokument ist kein Graustufen-Dokument derselben Größe mehr. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 58c2db19ea..ad261185bb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4200,6 +4200,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Μη έγκυρη αριθμητική τιμή. Απαιτείται ακέραιος αριθμός μεταξύ {min}% και {max}%. Εισήχθη η προηγούμενη τιμή. Color Aware Με βάση το χρώμα Decontaminate Colors Απολύμανση χρωμάτων @@ -4266,3 +4268,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable Το πλαίσιο δεν είναι διαθέσιμο X X Y Y + +# Channels panel menu (#11451) + New Channel… Νέο κανάλι… + Duplicate Channel Δημιουργία αντιγράφου καναλιού + Delete Channel Διαγραφή καναλιού + New Spot Channel… Νέο κανάλι πλακάτου χρώματος… + Merge Spot Channel Συγχώνευση καναλιού πλακάτου χρώματος + Channel Options… Επιλογές καναλιού… + Split Channels Διαχωρισμός καναλιών + Merge Channels… Συγχώνευση καναλιών… + Specify Channels: Καθορισμός καναλιών: + Not enough grayscale documents for this mode. Δεν υπάρχουν αρκετά έγγραφα σε κλίμακα του γκρι για αυτή τη λειτουργία. + Pick a different document for each channel. Επιλέξτε διαφορετικό έγγραφο για κάθε κανάλι. + A picked document is no longer open. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον ανοιχτό. + A picked document is no longer a grayscale document of the same size. Ένα από τα επιλεγμένα έγγραφα δεν είναι πλέον έγγραφο σε κλίμακα του γκρι ίδιου μεγέθους. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0863b02224..8f8c2d3ae8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4411,6 +4411,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica no válida. Se requiere un número entero entre {min}% y {max}%. Se ha insertado el valor anterior. Color Aware Según color Decontaminate Colors Descontaminar colores @@ -4475,3 +4477,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Reemplazar imagen… The frame is locked El marco está bloqueado The frame is unavailable El marco no está disponible + +# Channels panel menu (#11451) + Channel Options… Opciones de canal… + Specify Channels: Especificar canales: + Not enough grayscale documents for this mode. No hay suficientes documentos en escala de grises para este modo. + Pick a different document for each channel. Elija un documento distinto para cada canal. + A picked document is no longer open. Uno de los documentos elegidos ya no está abierto. + A picked document is no longer a grayscale document of the same size. Uno de los documentos elegidos ya no es un documento en escala de grises del mismo tamaño. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 53086c2582..90de37b0bf 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4788,3 +4788,11 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Pause For Tauko seconds sekuntia Create a single history state Luo yksi historiatila + +# Channels panel menu (#11451) + Channel Options… Kanavan asetukset… + Specify Channels: Määritä kanavat: + Not enough grayscale documents for this mode. Harmaasävyasiakirjoja ei ole tarpeeksi tähän tilaan. + Pick a different document for each channel. Valitse jokaiselle kanavalle eri asiakirja. + A picked document is no longer open. Yksi valituista asiakirjoista ei ole enää auki. + A picked document is no longer a grayscale document of the same size. Yksi valituista asiakirjoista ei ole enää samankokoinen harmaasävyasiakirja. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb0245ab35..75b955f0d7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4537,6 +4537,8 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrée numérique non valide. Un entier compris entre {min}% et {max}% est requis. La valeur précédente a été insérée. Color Aware Selon la couleur Decontaminate Colors Décontaminer les couleurs @@ -4598,3 +4600,11 @@ shape Appearance Apparence Replace Image… Remplacer l’image… The frame is locked Le cadre est verrouillé The frame is unavailable Le cadre n’est pas disponible + +# Channels panel menu (#11451) + Channel Options… Options de canal… + Specify Channels: Indiquer les canaux : + Not enough grayscale documents for this mode. Pas assez de documents en niveaux de gris pour ce mode. + Pick a different document for each channel. Choisissez un document différent pour chaque canal. + A picked document is no longer open. Un des documents choisis n'est plus ouvert. + A picked document is no longer a grayscale document of the same size. Un des documents choisis n'est plus un document en niveaux de gris de même taille. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 182b273467..f7a712b0e4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -567,6 +567,8 @@ cameraRaw Yellow पीला Target लक्ष्य Units इकाइयाँ Demosaic डिमोज़ेक + + The GPU compositor is still being prepared The GPU compositor is still being prepared Clear Selection चयन साफ़ करें Color Aware रंग-आधारित Decontaminate Colors रंग शुद्ध करें @@ -643,3 +645,18 @@ cameraRaw Yellow पीला Pause For इतने समय रुकें seconds सेकंड Create a single history state एक ही इतिहास स्थिति बनाएँ + +# Channels panel menu (#11451) + New Channel… नया चैनल… + Duplicate Channel चैनल को डुप्लीकेट करें + Delete Channel चैनल हटाएँ + New Spot Channel… नया स्पॉट चैनल… + Merge Spot Channel स्पॉट चैनल मर्ज करें + Channel Options… चैनल विकल्प… + Split Channels चैनल्स विभाजित करें + Merge Channels… चैनल्स मर्ज करें… + Specify Channels: चैनल्स निर्दिष्ट करें: + Not enough grayscale documents for this mode. इस मोड के लिए पर्याप्त ग्रेस्केल डॉक्यूमेंट नहीं हैं। + Pick a different document for each channel. हर चैनल के लिए अलग डॉक्यूमेंट चुनें। + A picked document is no longer open. चुना गया एक डॉक्यूमेंट अब खुला नहीं है। + A picked document is no longer a grayscale document of the same size. चुना गया एक डॉक्यूमेंट अब उसी आकार का ग्रेस्केल डॉक्यूमेंट नहीं है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f443e17885..debfdd6747 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4420,6 +4420,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Érvénytelen számbevitel. {min}% és {max}% közötti egész szám szükséges. Az előző érték lett beillesztve. Color Aware Színalapú Decontaminate Colors Színek fertőtlenítése @@ -4482,3 +4484,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Kép cseréje… The frame is locked A keret zárolva van The frame is unavailable A keret nem érhető el + +# Channels panel menu (#11451) + Channel Options… Csatornabeállítások… + Specify Channels: Csatornák megadása: + Not enough grayscale documents for this mode. Nincs elég szürkeárnyalatos dokumentum ehhez a módhoz. + Pick a different document for each channel. Minden csatornához másik dokumentumot válasszon. + A picked document is no longer open. Az egyik kiválasztott dokumentum már nincs megnyitva. + A picked document is no longer a grayscale document of the same size. Az egyik kiválasztott dokumentum már nem azonos méretű szürkeárnyalatos dokumentum. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d476cb0185..c6af32b023 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3519,6 +3519,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entri numerik tidak valid. Diperlukan bilangan bulat antara {min}% dan {max}%. Nilai sebelumnya telah disisipkan. Clear Selection Hapus Seleksi Color Aware Berbasis Warna @@ -3588,3 +3590,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is locked Bingkai terkunci The frame is unavailable Bingkai tidak tersedia Frame Bingkai + +# Channels panel menu (#11451) + New Channel… Channel Baru… + Duplicate Channel Duplikat Channel + Delete Channel Hapus Channel + New Spot Channel… Spot Channel Baru… + Merge Spot Channel Gabungkan Spot Channel + Channel Options… Opsi Channel… + Split Channels Pisahkan Channel + Merge Channels… Gabungkan Channel… + Specify Channels: Tentukan Channel: + Not enough grayscale documents for this mode. Dokumen grayscale tidak cukup untuk mode ini. + Pick a different document for each channel. Pilih dokumen yang berbeda untuk setiap channel. + A picked document is no longer open. Salah satu dokumen yang dipilih sudah tidak terbuka. + A picked document is no longer a grayscale document of the same size. Salah satu dokumen yang dipilih bukan lagi dokumen grayscale dengan ukuran yang sama. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7d109ac4b2..b120d5a32c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4613,6 +4613,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Valore numerico non valido. È richiesto un numero intero compreso tra {min}% e {max}%. È stato inserito il valore precedente. Color Aware In base al colore Decontaminate Colors Decontamina colori @@ -4674,3 +4676,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Sostituisci immagine… The frame is locked La cornice è bloccata The frame is unavailable La cornice non è disponibile + +# Channels panel menu (#11451) + Channel Options… Opzioni canale… + Specify Channels: Specifica canali: + Not enough grayscale documents for this mode. Documenti in scala di grigio insufficienti per questo metodo. + Pick a different document for each channel. Scegli un documento diverso per ogni canale. + A picked document is no longer open. Uno dei documenti scelti non è più aperto. + A picked document is no longer a grayscale document of the same size. Uno dei documenti scelti non è più un documento in scala di grigio delle stesse dimensioni. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7ddf07b49e..36c5bc1470 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4175,6 +4175,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 無効な数値です。{min}% から {max}% までの整数が必要です。以前の値が挿入されました。 Clear Selection 選択範囲をクリア Color Aware カラー認識 @@ -4243,3 +4245,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… 画像を置き換え… The frame is locked フレームはロックされています The frame is unavailable フレームを利用できません + +# Channels panel menu (#11451) + Channel Options… チャンネルオプション… + Specify Channels: チャンネルを指定: + Not enough grayscale documents for this mode. このモードに必要なグレースケールのドキュメントが足りません。 + Pick a different document for each channel. チャンネルごとに別のドキュメントを選択してください。 + A picked document is no longer open. 選択したドキュメントの一つが開かれていません。 + A picked document is no longer a grayscale document of the same size. 選択したドキュメントの一つが、同じサイズのグレースケールのドキュメントではなくなりました。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d74b6784e5..81d92d6ecd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3718,6 +3718,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 잘못된 숫자 입력입니다. {min}%에서 {max}% 사이의 정수가 필요합니다. 이전 값이 입력되었습니다. Clear Selection 선택 영역 지우기 Color Aware 색상 인식 @@ -3788,3 +3790,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded The frame is unavailable 프레임을 사용할 수 없습니다 X X Y Y + +# Channels panel menu (#11451) + New Channel… 새 채널… + Duplicate Channel 채널 복제 + Delete Channel 채널 삭제 + New Spot Channel… 새 별색 채널… + Merge Spot Channel 별색 채널 병합 + Channel Options… 채널 옵션… + Split Channels 채널 분할 + Merge Channels… 채널 병합… + Specify Channels: 채널 지정: + Not enough grayscale documents for this mode. 이 모드에 필요한 회색 음영 문서가 부족합니다. + Pick a different document for each channel. 채널마다 다른 문서를 선택하십시오. + A picked document is no longer open. 선택한 문서 중 하나가 더 이상 열려 있지 않습니다. + A picked document is no longer a grayscale document of the same size. 선택한 문서 중 하나가 더 이상 같은 크기의 회색 음영 문서가 아닙니다. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 55f348ff1e..05057c9f12 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4787,3 +4787,11 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Pause For Pause i seconds sekunder Create a single history state Opprett én historikktilstand + +# Channels panel menu (#11451) + Channel Options… Kanalalternativer… + Specify Channels: Angi kanaler: + Not enough grayscale documents for this mode. Ikke nok dokumenter i gråtoner for denne modusen. + Pick a different document for each channel. Velg et annet dokument for hver kanal. + A picked document is no longer open. Et av de valgte dokumentene er ikke lenger åpent. + A picked document is no longer a grayscale document of the same size. Et av de valgte dokumentene er ikke lenger et dokument i gråtoner av samme størrelse. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 418c756ff4..0788559d67 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4257,6 +4257,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ongeldige numerieke invoer. Er is een geheel getal tussen {min}% en {max}% vereist. De vorige waarde is ingevoegd. Color Aware Kleurbewust Decontaminate Colors Kleuren zuiveren @@ -4320,3 +4322,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Afbeelding vervangen… The frame is locked Het kader is vergrendeld The frame is unavailable Het kader is niet beschikbaar + +# Channels panel menu (#11451) + New Channel… Nieuw kanaal… + Duplicate Channel Kanaal dupliceren + Delete Channel Kanaal verwijderen + New Spot Channel… Nieuw steunkleurkanaal… + Merge Spot Channel Steunkleurkanaal samenvoegen + Channel Options… Kanaalopties… + Split Channels Kanalen splitsen + Merge Channels… Kanalen samenvoegen… + Specify Channels: Kanalen opgeven: + Not enough grayscale documents for this mode. Niet genoeg grijswaardendocumenten voor deze modus. + Pick a different document for each channel. Kies voor elk kanaal een ander document. + A picked document is no longer open. Een gekozen document is niet meer geopend. + A picked document is no longer a grayscale document of the same size. Een gekozen document is geen grijswaardendocument van hetzelfde formaat meer. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 52e6fd05a1..eb95ddf40b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4117,6 +4117,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Nieprawidłowa wartość liczbowa. Wymagana jest liczba całkowita od {min}% do {max}%. Wstawiono poprzednią wartość. Clear Selection Wyczyść zaznaczenie Color Aware Według kolorów @@ -4183,3 +4185,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Zastąp obraz… The frame is locked Ramka jest zablokowana The frame is unavailable Ramka jest niedostępna + +# Channels panel menu (#11451) + New Channel… Nowy kanał… + Duplicate Channel Duplikuj kanał + Delete Channel Usuń kanał + New Spot Channel… Nowy kanał kolorów dodatkowych… + Merge Spot Channel Scal kanał kolorów dodatkowych + Channel Options… Opcje kanału… + Split Channels Podziel kanały + Merge Channels… Scal kanały… + Specify Channels: Określ kanały: + Not enough grayscale documents for this mode. Za mało dokumentów w skali szarości dla tego trybu. + Pick a different document for each channel. Wybierz inny dokument dla każdego kanału. + A picked document is no longer open. Jeden z wybranych dokumentów nie jest już otwarty. + A picked document is no longer a grayscale document of the same size. Jeden z wybranych dokumentów nie jest już dokumentem w skali szarości o tym samym rozmiarze. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 044de6271e..8579545a54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4427,6 +4427,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica inválida. É necessário um número inteiro entre {min}% e {max}%. O valor anterior foi inserido. Color Aware Com reconhecimento de cor Decontaminate Colors Descontaminar cores @@ -4489,3 +4491,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Substituir imagem… The frame is locked A moldura está bloqueada The frame is unavailable A moldura não está disponível + +# Channels panel menu (#11451) + Channel Options… Opções de canal… + Specify Channels: Especificar canais: + Not enough grayscale documents for this mode. Não há documentos em tons de cinza suficientes para este modo. + Pick a different document for each channel. Escolha um documento diferente para cada canal. + A picked document is no longer open. Um dos documentos escolhidos não está mais aberto. + A picked document is no longer a grayscale document of the same size. Um dos documentos escolhidos não é mais um documento em tons de cinza do mesmo tamanho. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e449b92917..4b32187aa2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4698,6 +4698,8 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Недопустимое числовое значение. Требуется целое число от {min}% до {max}%. Вставлено предыдущее значение. Color Aware С учетом цвета Decontaminate Colors Очистить цвета @@ -4760,3 +4762,11 @@ shape Appearance Оформление Replace Image… Заменить изображение… The frame is locked Рамка заблокирована The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + Channel Options… Параметры канала… + Specify Channels: Указать каналы: + Not enough grayscale documents for this mode. Недостаточно документов в градациях серого для этого режима. + Pick a different document for each channel. Выберите отдельный документ для каждого канала. + A picked document is no longer open. Один из выбранных документов больше не открыт. + A picked document is no longer a grayscale document of the same size. Один из выбранных документов больше не является документом в градациях серого того же размера. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index cb3da7689c..ce8b82ae26 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4578,6 +4578,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je ceo broj između {min}% i {max}%. Umetnuta je prethodna vrednost. Color Aware Prema boji Decontaminate Colors Dekontaminiraj boje @@ -4641,3 +4643,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замени слику… The frame is locked Оквир је закључан The frame is unavailable Оквир није доступан + +# Channels panel menu (#11451) + Channel Options… Opcije kanala… + Specify Channels: Odredi kanale: + Not enough grayscale documents for this mode. Nema dovoljno dokumenata u nijansama sive za ovaj režim. + Pick a different document for each channel. Izaberite drugi dokument za svaki kanal. + A picked document is no longer open. Jedan izabrani dokument više nije otvoren. + A picked document is no longer a grayscale document of the same size. Jedan izabrani dokument više nije dokument u nijansama sive iste veličine. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 928a735904..e7f1f83489 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4785,3 +4785,11 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Pause For Paus i seconds sekunder Create a single history state Skapa ett enda historikläge + +# Channels panel menu (#11451) + Channel Options… Kanalalternativ… + Specify Channels: Ange kanaler: + Not enough grayscale documents for this mode. Det finns inte tillräckligt många gråskaledokument för det här läget. + Pick a different document for each channel. Välj ett annat dokument för varje kanal. + A picked document is no longer open. Ett av de valda dokumenten är inte längre öppet. + A picked document is no longer a grayscale document of the same size. Ett av de valda dokumenten är inte längre ett gråskaledokument i samma storlek. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e441028963..331eb4e2b3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4229,6 +4229,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Geçersiz sayısal giriş. {min}% ile {max}% arasında bir tam sayı gerekiyor. Önceki değer eklendi. Color Aware Renge Duyarlı Decontaminate Colors Renkleri Arındır @@ -4297,3 +4299,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Görüntüyü Değiştir… The frame is locked Çerçeve kilitli The frame is unavailable Çerçeve kullanılamıyor + +# Channels panel menu (#11451) + New Channel… Yeni Kanal… + Duplicate Channel Kanalı Çoğalt + Delete Channel Kanalı Sil + New Spot Channel… Yeni Spot Kanal… + Merge Spot Channel Spot Kanalı Birleştir + Channel Options… Kanal Seçenekleri… + Split Channels Kanalları Ayır + Merge Channels… Kanalları Birleştir… + Specify Channels: Kanalları Belirt: + Not enough grayscale documents for this mode. Bu mod için yeterli gri tonlamalı belge yok. + Pick a different document for each channel. Her kanal için farklı bir belge seçin. + A picked document is no longer open. Seçilen belgelerden biri artık açık değil. + A picked document is no longer a grayscale document of the same size. Seçilen belgelerden biri artık aynı boyutta gri tonlamalı bir belge değil. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5416f3d57f..cf51dca7f8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4210,6 +4210,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Неприпустиме числове значення. Потрібне ціле число від {min}% до {max}%. Вставлено попереднє значення. Clear Selection Очистити виділення Color Aware З урахуванням кольору @@ -4276,3 +4278,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Замінити зображення… The frame is locked Рамку заблоковано The frame is unavailable Рамка недоступна + +# Channels panel menu (#11451) + New Channel… Новий канал… + Duplicate Channel Дублювати канал + Delete Channel Видалити канал + New Spot Channel… Новий канал плашкового кольору… + Merge Spot Channel Об'єднати канал плашкового кольору + Channel Options… Параметри каналу… + Split Channels Розділити канали + Merge Channels… Об'єднати канали… + Specify Channels: Вказати канали: + Not enough grayscale documents for this mode. Недостатньо документів у градаціях сірого для цього режиму. + Pick a different document for each channel. Виберіть окремий документ для кожного каналу. + A picked document is no longer open. Один із вибраних документів більше не відкрито. + A picked document is no longer a grayscale document of the same size. Один із вибраних документів більше не є документом у градаціях сірого того самого розміру. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb746926fe..bda47054db 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4006,6 +4006,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Giá trị số không hợp lệ. Cần một số nguyên từ {min}% đến {max}%. Đã chèn giá trị trước đó. Clear Selection Xóa vùng chọn Color Aware Nhận biết màu @@ -4072,3 +4074,11 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Replace Image… Thay ảnh… The frame is locked Khung đã bị khóa The frame is unavailable Khung không khả dụng + +# Channels panel menu (#11451) + Channel Options… Tùy chọn kênh… + Specify Channels: Chỉ định kênh: + Not enough grayscale documents for this mode. Không đủ tài liệu thang xám cho chế độ này. + Pick a different document for each channel. Chọn một tài liệu khác cho mỗi kênh. + A picked document is no longer open. Một tài liệu đã chọn không còn mở. + A picked document is no longer a grayscale document of the same size. Một tài liệu đã chọn không còn là tài liệu thang xám cùng kích thước. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 86ddac9738..4a165910b9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3182,6 +3182,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 数值输入无效。需要介于 {min}% 和 {max}% 之间的整数。已插入先前的值。 Clear Selection 清除选区 Color Aware 颜色识别 @@ -3254,3 +3256,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 X X Y Y + +# Channels panel menu (#11451) + New Channel… 新建通道… + Duplicate Channel 复制通道 + Delete Channel 删除通道 + New Spot Channel… 新建专色通道… + Merge Spot Channel 合并专色通道 + Channel Options… 通道选项… + Split Channels 分离通道 + Merge Channels… 合并通道… + Specify Channels: 指定通道: + Not enough grayscale documents for this mode. 灰度文档不足,无法使用此模式。 + Pick a different document for each channel. 请为每个通道选择不同的文档。 + A picked document is no longer open. 所选的一个文档已不再打开。 + A picked document is no longer a grayscale document of the same size. 所选的一个文档已不再是同样大小的灰度文档。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4f6107659d..3d10a7aa6a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3138,6 +3138,8 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 + + The GPU compositor is still being prepared The GPU compositor is still being prepared Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 數值輸入無效。需要介於 {min}% 和 {max}% 之間的整數。已插入先前的值。 Clear Selection 清除選取範圍 Color Aware 顏色感知 @@ -3210,3 +3212,18 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Frame 框架 X X Y Y + +# Channels panel menu (#11451) + New Channel… 新增色版… + Duplicate Channel 複製色版 + Delete Channel 刪除色版 + New Spot Channel… 新增特別色色版… + Merge Spot Channel 合併特別色色版 + Channel Options… 色版選項… + Split Channels 分離色版 + Merge Channels… 合併色版… + Specify Channels: 指定色版: + Not enough grayscale documents for this mode. 灰階文件不足,無法使用此模式。 + Pick a different document for each channel. 請為每個色版選擇不同的文件。 + A picked document is no longer open. 所選的一個文件已不再開啟。 + A picked document is no longer a grayscale document of the same size. 所選的一個文件已不再是相同大小的灰階文件。 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c910d80aed..c102064a7f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -47,6 +47,18 @@ pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> { egui::Image::new(white_icons().source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint) } +/// Rasterize the same styled SVG used by the toolbar for a composed native cursor. +pub(crate) fn rasterize(name: &str, side: u32) -> Result { + if !white_icons().contains(name) { + return Err(format!("Cursor icon {name:?} is missing")); + } + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(name) else { + return Err(format!("Cursor icon {name:?} has no embedded SVG bytes")); + }; + egui_extras::image::load_svg_bytes_with_size(&bytes, egui::load::SizeHint::Width(side), &Default::default()) + .map_err(|error| format!("Could not rasterize cursor icon {name:?}: {error}")) +} + /// Paint an icon centred in `rect`. pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { let r = Rect::from_center_size(rect.center(), Vec2::splat(size)); diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 26dfa5e96f..8cf978e36c 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -820,8 +820,12 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f return true; } // In a full screen mode, the Crop tool's untouched default frame (Straighten off) has nothing to cancel: cancelling only - // makes the next one, so Esc goes on to leave full screen (#7315). - let untouched = app.ui.view.screen_mode != "standard" && app.ui.polygon.is_empty() && app.crop.default_frame && !crate::crop_straighten::mode(app); + // makes the next one, so Esc goes on to leave full screen (#7315). A gesture on it is still cancelled (#9275). + let untouched = app.ui.view.screen_mode != "standard" + && app.ui.polygon.is_empty() + && app.crop.default_frame + && app.crop.drag.is_none() + && !crate::crop_straighten::mode(app); if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index fb708d9438..aa6b066885 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1006,20 +1006,56 @@ fn eyedropper_and_alt_sampling_show_a_pipette() { assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, desktop)); } -/// The Paint Bucket shows a bucket instead of the crosshair (#3968); Precise Other Cursors keeps -/// the crosshair. +/// The bucket and fill target share one pointer bitmap or one painted image, and Precise Other Cursors hides both. #[test] fn paint_bucket_shows_a_bucket_cursor() { + fn cursor_area_visible(h: &Harness<'static, PhotocraftApp>, id: &str) -> bool { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(id)); + h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)) + } + let mut h = harness(); let p = h.state().last_canvas_rect.center(); h.state_mut().ui.tool = crate::state::Tool::PaintBucket; h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the bucket replaces the pointer"); + + let output = h.output(); + if cfg!(not(target_arch = "wasm32")) { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Crosshair, "the bitmap keeps a visible fallback"); + let cursor = output.platform_output.cursor_image.as_ref().expect("native root viewport uses the unified bucket bitmap"); + let [hot_x, hot_y] = cursor.hotspot; + let hot_pixel = (usize::from(hot_y) * usize::from(cursor.size[0]) + usize::from(hot_x)) * 4 + 3; + assert_eq!(cursor.rgba[hot_pixel], 0, "the Gradient-style crosshair leaves its exact bitmap hotspot open"); + let side = usize::from(cursor.size[0]); + assert!( + cursor.rgba.as_chunks::<4>().0.iter().enumerate().any(|(i, pixel)| { + let x = (i % side) as u16; + let y = (i / side) as u16; + pixel[3] > 200 && x + 2 < hot_x && y + 2 < hot_y + }), + "the bucket artwork is opaque above-left of the same hotspot" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "native cursor has no frame-painted icon overlay"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "native cursor does not create a painted cursor Area"); + } else { + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::None, "the painted image hides the OS arrow"); + assert!(cursor_area_visible(&h, "pc-tool-cursor"), "scaled or web cursor uses one unified image Area"); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "the old icon overlay is absent"); + } + assert_eq!(h.ctx.pointer_latest_pos(), Some(p), "the click target stays at the cursor hotspot"); + h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap(); - h.hover_at(p); h.run_steps(2); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Crosshair, "Precise keeps the crosshair"); + let precise = h.output(); + assert_eq!(precise.platform_output.cursor_icon, egui::CursorIcon::Crosshair); + assert_eq!( + precise.platform_output.cursor_image.is_some(), + cfg!(not(target_arch = "wasm32")), + "Precise uses its crosshair bitmap on native and the system cursor on web" + ); + assert!(!cursor_area_visible(&h, "pc-icon-cursor"), "Precise omits the bucket artwork"); + assert!(!cursor_area_visible(&h, "pc-tool-cursor"), "Precise omits the painted bucket image"); } #[test] diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 620574f909..66ac7a8c45 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -70,6 +70,17 @@ pub(crate) struct OpenBatch { opened: Vec, } +/// A file being placed in the background (File › Place, or a file dropped on the canvas): Free +/// Transform starts on its layer when the job lands, and the files dropped after it follow. +#[derive(Clone, Debug)] +pub struct PlaceJob { + pub job: JobId, + /// Display name (the file name). + pub name: String, + /// The document it lands in: Free Transform starts only while that one is in view. + pub doc: DocId, +} + /// The job command of a background save. pub const SAVE_JOB: &str = "file.save"; @@ -165,6 +176,9 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// The file being placed in the background, if any. One at a time: files dropped meanwhile + /// wait in the app's drop queue. + pub place: Option, /// Cached read-only canvas image of the current folder Batch input. batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } @@ -188,6 +202,62 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result, linked: Option, params: &Value) -> Result { + if app.jobs.place.is_some() { + return Err(tl!("A file is still being placed; try again when it has finished.").into()); + } + match app.session.start_place_bytes(name, bytes, linked, params.clone()).map_err(|e| e.to_string())? { + Started::Done(v) => Ok(v), + Started::Job(job) => { + app.jobs.last_started = Some(job); + // The status bar's progress readout names the job; clear any old message. + app.ui.status.clear(); + app.ui.status_error = false; + Ok(json!({"job": job.0, "pending": true})) + } + } +} + +/// Follow the place that `started` (the result of [`run`] or [`start_place_bytes`] for the file +/// `name`) began in the background: when it lands, Free Transform starts on its layer and the +/// next dropped file is placed (see `on_event`). False when it already ran (nothing to follow). +pub(crate) fn track_place(app: &mut PhotocraftApp, name: &str, started: &Value) -> bool { + let job = started.get("pending").and_then(Value::as_bool).filter(|pending| *pending).and_then(|_| started.get("job")?.as_u64()); + let (Some(job), Some(doc)) = (job, app.session.active().map(|st| st.doc.id)) else { return false }; + app.jobs.place = Some(PlaceJob { job: JobId(job), name: crate::file_open::display_name(name), doc }); + true +} + +/// A background place ended: start Free Transform on the layer it made, or report why not, then +/// go on with the files dropped meanwhile. +fn finish_place(app: &mut PhotocraftApp, ctx: &egui::Context, place: PlaceJob, outcome: JobOutcome) { + app.sync_views(); + match outcome { + JobOutcome::Done(result) => { + // The tab can change while the file is placed: the box opens only on the document it + // was placed in, and never over another one. An image placed into a frame has none. + if app.ui.transform.is_none() + && result.get("frame").and_then(Value::as_u64).is_none() + && app.session.active().is_some_and(|st| st.doc.id == place.doc) + && let Err(e) = crate::transform_tool::begin_placed(app, ctx) + { + app.ui.status = e; + } + } + JobOutcome::Failed(err) => app.place_failed(&place.name, &err), + JobOutcome::Cancelled if app.session.documents().iter().any(|st| st.doc.id == place.doc) => { + app.ui.status = crate::i18n::fmt(tl!("Cancelled placing {name}"), &[("name", &place.name)]); + app.ui.status_error = false; + } + JobOutcome::Cancelled => {} + } + app.place_next_dropped(ctx); +} + /// Open a file in the background: a tab appears at once and shows the progress; the document /// replaces it when decoded. `path` is remembered for File › Save and Open Recent. `slot` (a file /// dropped on the tabs) and `order` (its place in a set opened together) survive an inline @@ -286,6 +356,7 @@ pub fn start_save( // The engine's wording for a command refused while a job runs. return Err(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", j.label)); } + crate::app::ensure_memory_to_save(&st.doc, &path)?; let name = crate::file_open::display_name(&path); // One writer per file: an older snapshot must not land after a newer one. if app.jobs.saves.iter().any(|s| s.path == path) { @@ -508,6 +579,12 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { if crate::camera_raw_ui::on_redevelop_event(app, &e) { return; } + if app.jobs.place.as_ref().is_some_and(|p| p.job == e.id) + && let Some(place) = app.jobs.place.take() + { + finish_place(app, ctx, place, e.outcome); + return; + } if e.command == SAVE_JOB && let Some(i) = app.jobs.saves.iter().position(|s| s.job == e.id) { @@ -844,7 +921,13 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(j) => j, None => { let Some(j) = app.session.active_job() else { return }; - if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + // A save shows its progress in the tab and the status bar; so does a place, which + // leaves the view free to zoom and pan while the file is decoded. + let place = place_uses_status_bar( + &j.command, + !app.ui.view.hides_chrome() && app.ui.panels.status_bar && job_in_view(app).is_some_and(|shown| shown.id == j.id), + ); + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || place || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -1007,6 +1090,7 @@ pub fn inspect(app: &PhotocraftApp) -> Value { "running": app.session.jobs(), "opening": app.jobs.opens.iter().map(|o| json!({"job": o.job.0, "name": o.name, "path": o.path})).collect::>(), "saving": app.jobs.saves.iter().map(|s| json!({"job": s.job.0, "path": s.path})).collect::>(), + "placing": app.jobs.place.as_ref().map(|p| json!({"job": p.job.0, "name": p.name})), "focus": app.jobs.focus.map(|j| j.0), "background": app.background_jobs, }) @@ -1019,3 +1103,20 @@ pub fn bytes(b: &[u8]) -> OpenSource { #[cfg(test)] mod tests; + +/// Placements keep the canvas accessible when progress and Cancel are visible in the status bar. +fn place_uses_status_bar(command: &str, status_bar_visible: bool) -> bool { + status_bar_visible && matches!(command, "file.placeEmbedded" | "file.placeLinked") +} + +#[cfg(test)] +mod place_progress_tests { + #[test] + fn placement_modal_depends_on_command_and_visible_progress() { + for command in ["file.placeEmbedded", "file.placeLinked"] { + assert!(super::place_uses_status_bar(command, true)); + assert!(!super::place_uses_status_bar(command, false)); + } + assert!(!super::place_uses_status_bar("filter.blur", true)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 54edda6264..d042afba59 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod edit_session; pub mod file_dialog; pub mod file_open; pub mod frame_stages; +pub mod gpu_memo; pub mod gpu_status; pub mod home_recent_ui; pub mod i18n; @@ -41,6 +42,7 @@ pub mod icons; pub mod jobs_ui; pub(crate) mod last_brush; pub mod links; +pub mod memory_warning; pub mod menu_catalog; pub mod menus; pub mod monitor_status; @@ -258,8 +260,8 @@ pub use services::{ AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn, CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, - LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, - SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, + LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, + SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; @@ -456,6 +458,8 @@ pub struct PhotocraftApp { /// the frame the pointer leaves, so a long stationary hover never counts as elapsed time /// (#2022); set by `notices::show`. pub(crate) notices_hovered: bool, + /// The low-memory warning's schedule and state (`memory_warning`). + pub(crate) low_memory: memory_warning::Watch, /// Apply theme on first frame. styled: bool, /// Whether the window uses an integrated (transparent) macOS title bar. @@ -482,10 +486,11 @@ pub struct PhotocraftApp { pub(crate) drop_canvas_rect: Option, /// The document tab strip last frame: a drop there opens the file at the slot under it. pub(crate) tab_strip: Option, - /// Files dropped on the canvas still to place, one Free Transform at a time. pub(crate) frame_tool_ctx: Option, pub(crate) frame_preview: Option, - pub(crate) drop_places: std::collections::VecDeque, + /// Files dropped on the canvas still to place, one Free Transform at a time, each with the + /// document it was dropped on. + pub(crate) drop_places: std::collections::VecDeque<(DocId, file_open::DropPlace)>, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, /// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths @@ -497,9 +502,10 @@ pub struct PhotocraftApp { /// The document area each layer thumbnail in `thumbs` frames (the canvas, or the layer's /// bounds with Clip Thumbnails to Layer Bounds; #4281). thumb_frames: HashMap, - /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each - /// is made once (`snapshot_thumb`, #2966). - snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, + /// History snapshot thumbnails by (document, snapshot id), each with the snapshot document it + /// shows (`snapshot_thumb`, #2966): made once, and again only when that is a different one (a + /// preserved-ID reopen, or the first snapshot refreshed for a served font, #6087). + snapshot_thumbs: HashMap<(DocId, u64), (std::sync::Weak, egui::TextureHandle)>, /// Snapshots whose live layer/mask keys were last used to prune thumbnail handles. thumb_documents: Vec<(DocId, std::sync::Weak)>, /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. @@ -875,6 +881,9 @@ mod blend_dropdown_wheel_tests; #[cfg(test)] mod paths_scroll_tests; +#[cfg(test)] +mod blend_dropdown_groups_tests; + #[cfg(test)] mod options_bar_mode_wheel_tests; @@ -921,6 +930,8 @@ mod window_frame_tests; #[cfg(test)] mod browse_field_tests; #[cfg(test)] +mod export_quality_tests; +#[cfg(test)] mod options_hit_area_tests; #[cfg(test)] mod select_and_mask_button_tests; diff --git a/crates/ui-egui/src/memory_warning.rs b/crates/ui-egui/src/memory_warning.rs new file mode 100644 index 0000000000..366656d33e --- /dev/null +++ b/crates/ui-egui/src/memory_warning.rs @@ -0,0 +1,137 @@ +//! A standing warning while the web build's memory is nearly full (#10024). +//! +//! On the web a failed allocation aborts the module and the page freezes without a message. The +//! big operations refuse up front when they won't fit (`photocraft_engine::memory_gate`); this +//! tells the user earlier, while there is still room to save: when the module's memory is within +//! [`WARN_BELOW`] of the wasm32 ceiling, a notice that stays until dismissed asks them to save and +//! free memory. The answer comes from [`photocraft_raster::headroom::below`], which reads the +//! size of linear memory and allocates nothing. Linear memory never shrinks, so on the web the +//! warning doesn't re-arm once shown (only `?free-mb` or a test can make memory recover). It is always "plenty" on +//! the desktop, so the warning never shows there. + +use photocraft_raster::headroom; + +use crate::PhotocraftApp; + +/// Warn when less than this can be allocated. +pub const WARN_BELOW: u64 = 512 << 20; + +/// Warn again only after this much (or more) was available again, so a figure hovering around +/// the threshold doesn't flicker the notice on and off. +pub const REARM_ABOVE: u64 = 768 << 20; + +/// Seconds between checks. +const CHECK_EVERY: f64 = 2.0; + +/// The notice's title (an English source string, translated when drawn). +pub const TITLE: &str = "Memory is nearly full"; + +/// What a check decided. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + /// Nothing to do. + Idle, + /// Memory just ran low: post the notice. + Warn, + /// Memory recovered: the warning is armed again, and the old notice is stale. + Rearm, +} + +/// The warning's memory: when it last looked, and whether it has warned and not yet re-armed. +#[derive(Clone, Debug, Default)] +pub struct Watch { + next_check: f64, + warned: bool, +} + +impl Watch { + /// `true` when it is time to look again at `now` (seconds); then schedules the next look. + pub fn due(&mut self, now: f64) -> bool { + if now < self.next_check { + return false; + } + self.next_check = now + CHECK_EVERY; + true + } + + /// `true` between a warning and its re-arming. + pub fn warned(&self) -> bool { + self.warned + } + + /// Advances with what a check found: `below_warn` is "less than [`WARN_BELOW`] is available", + /// `below_rearm` is "less than [`REARM_ABOVE`] is available". + pub fn step(&mut self, below_warn: bool, below_rearm: bool) -> Step { + if !self.warned { + if below_warn { + self.warned = true; + return Step::Warn; + } + return Step::Idle; + } + if below_rearm { + return Step::Idle; + } + self.warned = false; + Step::Rearm + } +} + +/// Run once per frame: at most every two seconds, and only while a document is open, posts the +/// warning when memory is nearly full, and takes it down again once memory has recovered. +pub fn tick(app: &mut PhotocraftApp, now: f64) { + if app.session.active().is_none() || !app.low_memory.due(now) { + return; + } + let below_warn = headroom::below(WARN_BELOW); + let below_rearm = if app.low_memory.warned() { headroom::below(REARM_ABOVE) } else { below_warn }; + match app.low_memory.step(below_warn, below_rearm) { + Step::Idle => {} + Step::Warn => post(app), + Step::Rearm => app.ui.notices.retain(|n| n.title != TITLE), + } +} + +fn post(app: &mut PhotocraftApp) { + if app.ui.notices.iter().any(|n| n.title == TITLE) { + return; + } + let lines = vec![ + "Save your work now: if the browser runs out of memory this page stops, and changes since the last save can't be recovered.".to_owned(), + "To make room, close documents you don't need or use Edit › Purge › All.".to_owned(), + ]; + let id = crate::notices::post(app, TITLE, lines, true, None); + if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) { + notice.sticky = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warns_once_then_waits_for_the_higher_threshold() { + let mut w = Watch::default(); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + // Staying low, or hovering between the two thresholds, doesn't warn again. + assert_eq!(w.step(true, true), Step::Idle); + assert_eq!(w.step(false, true), Step::Idle); + assert_eq!(w.step(true, true), Step::Idle); + // Rising above the higher threshold re-arms; dropping again warns again. + assert_eq!(w.step(false, false), Step::Rearm); + assert_eq!(w.step(false, false), Step::Idle); + assert_eq!(w.step(true, true), Step::Warn); + } + + #[test] + fn checks_are_spaced_two_seconds_apart() { + let mut w = Watch::default(); + assert!(w.due(0.0)); + assert!(!w.due(1.0)); + assert!(!w.due(1.99)); + assert!(w.due(2.0)); + assert!(!w.due(3.0)); + } +} diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index e1f52d934c..0539b4cb0a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -415,20 +415,36 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::new_group_ui::open(app, id)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while - // in Quick Mask (with params: the engine directly). + // channel (also in Quick Mask mode, where its row is the selected one), Quick Mask Options + // in Quick Mask otherwise, or Layer Mask Display Options on a targeted layer mask (with + // params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { - _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), + _ if st.doc.quick_mask.is_some() => json!("quickMask"), _ if mask => json!("mask"), _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) } + // Channels › New Channel… without params: its dialog (with params: the engine). The + // panel's New button makes one at once, as Photoshop's does. + crate::channel_options_ui::NEW_CHANNEL if params.as_object().is_none_or(|o| o.is_empty()) => { + let dialog = crate::channel_options_ui::open_new_channel(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } + // Channels › Merge Channels… without params: its dialog, which picks the mode and the + // documents (with params: the engine). + crate::dialogs::merge_channels_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) { + return Err(why); + } + let dialog = crate::dialogs::merge_channels_ui::open(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Channels › New Spot Channel… without params: its dialog (with params: the engine). crate::channel_options_ui::NEW_SPOT if params.as_object().is_none_or(|o| o.is_empty()) => { let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 5c501fc2e9..16cefbd87b 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -66,8 +66,10 @@ pub(super) fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: if let Value::Object(m) = fields { f.extend(m); } - // Snapshot user-facing params at open so Alt+Reset can restore them. - let orig: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + // Snapshot user-facing params at open so Alt+Reset can restore them, with the Custom + // quality rows' inactive state. + let orig: Map = + f.iter().filter(|(k, _)| !k.starts_with("__") || RESET_SHELL_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); f.insert("__formOrig".into(), Value::Object(orig)); app.ui.open_dialog(DialogKind::Command, f) } @@ -166,18 +168,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { 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(); - let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; - ui.horizontal(|ui| { - crate::widgets::checkbox(ui, &mut custom, tl!("Custom")); - ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); - }); + let mut quality = v.as_f64().or_else(|| f.get(BATCH_QUALITY).and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, true); // None preserves the command's existing JPEG and lossless WebP defaults. // The inactive custom value is shell state, never a command parameter. - f.insert("__batchQuality".into(), json!(quality)); + f.insert(BATCH_QUALITY.into(), json!(quality)); f.insert(k, if custom { json!(quality) } else { Value::Null }); ui.end_row(); continue; } + if k == "quality" && f.contains_key(JPEG_QUALITY) { + ui.label(label(&k)); + jpeg_quality_field(ui, f); + ui.end_row(); + continue; + } let new = match &v { Value::Array(_) | Value::Object(_) | Value::Null => continue, Value::Bool(b) => { @@ -246,6 +251,42 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { camera_data(ui, f); } +/// Batch's inactive Custom quality (#3956): shell state, never a command parameter. +const BATCH_QUALITY: &str = "__batchQuality"; +/// An export-to-files dialog's Custom checkbox and its quality (#11481), kept while the format +/// is not JPEG or Custom is off: shell state, never command parameters. +const JPEG_CUSTOM: &str = "__jpegCustom"; +const JPEG_QUALITY: &str = "__jpegQuality"; +/// The shell keys Alt+Reset restores along with a form's params. +const RESET_SHELL_KEYS: [&str; 3] = [BATCH_QUALITY, JPEG_CUSTOM, JPEG_QUALITY]; + +/// A Custom checkbox and the 0..12 Quality field it enables (Batch, the exports to files). Off, +/// the command gets no quality and keeps its own defaults; `usable` false greys out both. +fn custom_quality_row(ui: &mut egui::Ui, custom: &mut bool, quality: &mut f32, usable: bool) { + ui.add_enabled_ui(usable, |ui| { + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, custom, tl!("Custom")); + ui.add_enabled_ui(*custom, |ui| crate::widgets::value_field(ui, quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality"))); + }); + }); +} + +/// Layers, Layer Comps and Artboards to Files: the JPEG quality, sent only for a JPEG with Custom +/// on. Left alone, the export writes what the command writes with no quality (the source JPEG's +/// own quality, else the encoder default), as Batch does; a quality would also make a WebP lossy +/// (its default is lossless), and PNG, PSD, TIFF and BMP have none. +fn jpeg_quality_field(ui: &mut egui::Ui, f: &mut Map) { + let jpeg = f.get("format").and_then(Value::as_str).is_some_and(|x| x.eq_ignore_ascii_case("jpg") || x.eq_ignore_ascii_case("jpeg")); + let sent = f.get("quality").and_then(Value::as_f64); + let mut custom = sent.is_some() || f.get(JPEG_CUSTOM).and_then(Value::as_bool).unwrap_or(false); + let mut quality = sent.or_else(|| f.get(JPEG_QUALITY).and_then(Value::as_f64)).unwrap_or(10.0).clamp(0.0, 12.0) as f32; + custom_quality_row(ui, &mut custom, &mut quality, jpeg); + let quality = quality.round().clamp(0.0, 12.0) as i64; + f.insert(JPEG_CUSTOM.into(), json!(custom)); + f.insert(JPEG_QUALITY.into(), json!(quality)); + f.insert("quality".into(), if jpeg && custom { json!(quality) } else { Value::Null }); +} + /// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP). fn camera_data(ui: &mut egui::Ui, f: &Map) { let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return }; @@ -323,7 +364,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para app.refocus(doc)?; let linked = linked.then(|| name.clone()); let result = app.place_bytes(&name, bytes, linked)?; - if result.get("frame").and_then(Value::as_u64).is_none() { + // In the background, Free Transform starts when the layer has landed. + if result.get("frame").and_then(Value::as_u64).is_none() && !crate::jobs_ui::track_place(app, &name, &result) { crate::transform_tool::begin_placed(app, &ctx)?; } Ok(result) @@ -427,7 +469,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "quality": null, JPEG_CUSTOM: false, + JPEG_QUALITY: 10, "visibleOnly": true, "trim": false, "__order": ["dir", "prefix", "visibleOnly", "format", "quality", "trim"]}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) } @@ -435,9 +478,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); let fields = if id == "file.export.layerCompsToFiles" { - json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "selectedOnly": false, + "__order": ["dir", "prefix", "selectedOnly", "format", "quality"]}) } else { - json!({"dir": dir, "prefix": prefix, "format": "png"}) + json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "__order": ["dir", "prefix", "format", "quality"]}) }; dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) } @@ -656,6 +700,7 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { /// are any. pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { let settings = choices.apply(app.save_settings()); + crate::app::ensure_memory_to_save(doc, path)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 52df38d96c..8b80767e6f 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -764,6 +764,29 @@ fn esc_leaves_full_screen_with_the_crop_tool_showing_its_default_frame() { assert_eq!(app.ui.view.screen_mode, "standard"); } +/// #9275: a gesture on the default frame is something to cancel, so Esc in full screen cancels it +/// (as in Standard Screen Mode) and full screen stays. +#[test] +fn esc_in_full_screen_cancels_a_crop_drag_on_the_default_frame() { + use crate::canvas::{ToolEvent, tool_event}; + let none = egui::Modifiers::NONE; + // Inside draws a new box, on the corner handle resizes, outside turns. + for (what, from) in [("draw", [20.0, 20.0]), ("resize", [100.0, 80.0]), ("turn", [130.0, 110.0])] { + let (mut app, ctx) = app_with(1); + app.ui.tool = crate::Tool::Crop; + crate::crop_ui::ensure_frame(&mut app); + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 50.0, pressure: 1.0 }, none); + assert!(app.crop.drag.is_some() && app.crop.default_frame, "{what}: mid-gesture on the default frame"); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen", "{what}: Esc cancels the gesture, full screen stays"); + assert!(app.crop.drag.is_none(), "{what}: the gesture is cancelled"); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 50.0 }, none); + assert!(!crate::crop_ui::pending(&app), "{what}: the release leaves no pending crop"); + } +} + #[test] fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { let (mut app, ctx) = app_with(1); diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 4b3ae45cac..f2025a0d78 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -35,6 +35,10 @@ pub struct Notice { /// and is reset while the pointer rests on the stack (issue #2022). `None` until first shown. #[serde(default, skip_serializing_if = "Option::is_none")] pub shown_at: Option, + /// Stays until dismissed (or until whatever it warns of has passed), whatever the auto-hide + /// setting: for a warning the user must not miss, such as nearly full memory. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sticky: bool, } impl Notice { @@ -48,14 +52,23 @@ impl Notice { /// Show a notice (newest last); returns its id. pub fn post(app: &mut PhotocraftApp, title: impl Into, lines: Vec, error: bool, dismiss_pref: Option<&str>) -> u64 { let id = app.ui.alloc_id(); - app.ui.notices.push(Notice { id, title: title.into(), lines, error, dismiss_pref: dismiss_pref.map(str::to_owned), args: Vec::new(), shown_at: None }); + app.ui.notices.push(Notice { + id, + title: title.into(), + lines, + error, + dismiss_pref: dismiss_pref.map(str::to_owned), + args: Vec::new(), + shown_at: None, + sticky: false, + }); cap_notices(app); id } fn cap_notices(app: &mut PhotocraftApp) { while app.ui.notices.len() > MAX_NOTICES { - let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none()).unwrap_or(0); + let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none() && !notice.sticky).unwrap_or(0); app.ui.notices.remove(oldest_temporary); } } @@ -120,7 +133,11 @@ pub fn clipboard_unreadable(app: &mut PhotocraftApp) { /// `ms`. The canvas doesn't respond while it runs, so once per document say why it was slow and /// which edits will do it again (#1015). pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Option<&crate::render_path::CpuReason>) { - if ms.is_nan() || ms < SLOW_CPU_REFRESH_MS || app.ui.slow_refresh_noticed.contains(&doc) { + if matches!(reason, Some(crate::render_path::CpuReason::CompositorBuilding)) + || ms.is_nan() + || ms < SLOW_CPU_REFRESH_MS + || app.ui.slow_refresh_noticed.contains(&doc) + { return; } app.ui.slow_refresh_noticed.push(doc); @@ -186,7 +203,7 @@ fn expire(app: &mut PhotocraftApp, now: f64, hovered: bool, just_left: bool, aut } let mut expired = Vec::new(); let mut next = f64::INFINITY; - for n in &mut app.ui.notices { + for n in app.ui.notices.iter_mut().filter(|n| !n.sticky) { if n.shown_at.is_none() { n.shown_at = Some(now); } @@ -402,6 +419,14 @@ mod tests { assert!(app.ui.notices.is_empty()); } + #[test] + fn preparing_compositor_does_not_warn_about_large_documents() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + slow_cpu_refresh(&mut app, 1, 60_000.0, Some(&crate::render_path::CpuReason::CompositorBuilding)); + assert!(app.ui.notices.is_empty()); + assert!(app.ui.slow_refresh_noticed.is_empty()); + } + #[test] fn a_slow_cpu_refresh_is_explained_once_per_document() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ab5475ff9b..80e535597a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -262,6 +262,13 @@ mod title_tests { } } +/// Whether blend mode `m` starts one of the groups Photoshop's blend mode lists draw a line above: +/// Normal, Dissolve (and Pass Through, Clear) | darken | lighten | contrast | difference | colour +/// (#5532). The first mode of a list never gets a line. +pub(crate) fn starts_blend_group(m: BlendMode) -> bool { + matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) +} + fn label(ui: &mut egui::Ui, s: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(s).color(t.text_dim)); @@ -1001,6 +1008,35 @@ mod snapshot_row_tests { assert!(h.state().ui.dialogs.is_empty()); } + /// Snapshot thumbnails belong to the snapshot's document (#6087): closing a document drops + /// them, and a document reopened with the same id (a `.pcraft` keeps it) gets fresh ones + /// rather than the closed document's, even before the views are synced. + #[test] + fn snapshot_thumbnails_follow_their_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let doc = (*app.session.active().unwrap().doc).clone(); + let doc_id = doc.id; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + let thumb = |h: &Harness<'static, PhotocraftApp>| h.state().snapshot_thumbs.get(&(doc_id, 0)).map(|(_, t)| t.id()); + let first = thumb(&h).expect("the first snapshot's thumbnail"); + // Closed: its thumbnails go with it. + h.state_mut().session.close(0); + h.state_mut().sync_views(); + assert!(h.state().snapshot_thumbs.keys().all(|(d, _)| *d != doc_id), "closing drops them"); + h.state_mut().session.add_document(doc.clone(), None); + h.run_steps(2); + let reopened = thumb(&h).expect("a thumbnail for the reopened document"); + assert_ne!(reopened, first); + // Closed and reopened between two frames, with no sync in between: still a fresh one. + h.state_mut().session.close(0); + h.state_mut().session.add_document(doc, None); + assert_eq!(h.state().session.active().unwrap().doc.id, doc_id, "same id"); + h.run_steps(2); + assert_ne!(thumb(&h).unwrap(), reopened); + } + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not /// carry the edit over to the other document's snapshot with the same id. #[test] diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d618999d9f..ee83682dcd 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,11 +1,13 @@ -use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; +use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, starts_blend_group, vec2, widgets}; /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the -/// mode. Returns the mode picked this frame. +/// mode. Returns the mode picked this frame. A list of every blend mode draws the Layers panel's +/// group lines (#5532; Clear starts none); a short one (the Healing Brush's) draws none. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { let opts: Vec<(Option, &str)> = modes.iter().map(|m| (*m, m.map_or("Clear", BlendMode::label))).collect(); + let grouped = BlendMode::LAYER_MODES.iter().all(|m| modes.contains(&Some(*m))); let mut mode = current; - let chosen = widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0; + let chosen = widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| grouped && m.is_some_and(starts_blend_group)).0; chosen.last().copied() } @@ -21,7 +23,7 @@ pub(super) fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraf } else { (Some(b.mode), false) }; - if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() { + if !widgets::dropdown_wheel_grouped(ui, id, tl!("Mode"), &mut mode, &opts, 96.0, |m| m.0.is_some_and(starts_blend_group)).0.is_empty() { *clear = mode == (None, false); b.behind = mode.1; if mode.1 { diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 3baa1b99bf..fd6ad08178 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -90,13 +90,15 @@ struct RowInteractions<'a> { masked: Option<&'a photocraft_doc::Layer>, actions: &'a mut Vec<(String, Value)>, mask_click: &'a mut Option, + /// Whether Merge Channels… can run (enough grayscale documents of one size). + merge_enabled: bool, } fn handle_row_interactions(ui: &mut egui::Ui, mut row: RowInteractions<'_>) { let rename_id = egui::Id::unique(("chan-rename", row.doc.id.0, format!("{:?}", row.row.reference()))); handle_channel_row_click(ui, &mut row); handle_channel_row_double_click(ui, &mut row, rename_id); - row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.actions)); + row.response.context_menu(|ui| draw_row_menu(ui, row.row, row.doc, row.masked, row.merge_enabled, row.actions)); } fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { @@ -167,7 +169,14 @@ fn handle_alpha_double_click(ui: &mut egui::Ui, row: &mut RowInteractions<'_>, i } } -fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, masked: Option<&photocraft_doc::Layer>, actions: &mut Vec<(String, Value)>) { +fn draw_row_menu( + ui: &mut egui::Ui, + row: Row, + doc: &photocraft_doc::Document, + masked: Option<&photocraft_doc::Layer>, + merge_enabled: bool, + actions: &mut Vec<(String, Value)>, +) { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(210.0); match row { @@ -177,11 +186,11 @@ fn draw_row_menu(ui: &mut egui::Ui, row: Row, doc: &photocraft_doc::Document, ma Row::LayerMask => draw_mask_menu(ui, masked, actions), } ui.separator(); - item(ui, actions, "New Channel…", "channel.new", json!({})); + item(ui, actions, "New Channel…", "ui.newChannel", json!({})); item(ui, actions, "New Spot Channel…", "ui.newSpotChannel", json!({})); ui.separator(); item(ui, actions, "Split Channels", "channel.split", json!({})); - item(ui, actions, "Merge Channels…", "channel.merge", json!({})); + item_enabled(ui, actions, "Merge Channels…", "ui.mergeChannels", json!({}), merge_enabled); }); } @@ -288,14 +297,14 @@ fn row_presentation(row: Row, context: &RowPresentationContext<'_>, mask_visible !context.view.quick_mask_hidden, context.view.target == ChannelTarget::Composite, ), - Row::LayerMask => { - (context.masked.map_or_else(String::new, |layer| format!("{} Mask", layer.name)), usize::MAX, None, mask_visible, context.mask_targeted) - } + Row::LayerMask => (context.masked.map_or_else(String::new, mask_row_name), usize::MAX, None, mask_visible, context.mask_targeted), }; RowPresentation { name, thumb_idx, slot, visible, selected } } pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // Greyed in the rows' menu as in the panel menu and the command palette. + let merge_enabled = crate::menus::is_enabled(app, "channel.merge"); let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { ui.add_space(6.0); @@ -316,10 +325,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The active layer's mask (Photoshop lists it, in italics, below the colour channels). let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()); let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?))); - let mask_targeted = masked.is_some() - && (app.ui.mask_target || view.target == ChannelTarget::LayerMask) - && (view.target == ChannelTarget::Composite || view.target == ChannelTarget::LayerMask) - && !quick; + let mask_targeted = mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick); // Multichannel images are their ink channels only (no composite or colour rows). let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel; let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] }; @@ -436,6 +442,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { masked, actions: &mut actions, mask_click: &mut mask_click, + merge_enabled, }, ); } @@ -467,13 +474,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }; // Photoshop's order from the left: Load as selection, Save selection, New, Delete. widgets::panel_footer(ui, |ui| { - let target_ref = match view.target { - ChannelTarget::Alpha(i) => json!(i), - ChannelTarget::Color(k) => json!({ "color": k }), - ChannelTarget::Composite => json!("composite"), - ChannelTarget::LayerMask => json!("mask"), - ChannelTarget::VectorMask => json!("vectorMask"), - }; + let target_ref = target_reference(view.target); let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")); if trash.clicked() { actions.push(("channel.delete".into(), json!({}))); @@ -510,6 +511,20 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } + // The row menu's New Channel… and Merge Channels… ask first, as the panel menu's do + // (the footer's New button makes a channel at once). + let asks = match id.as_str() { + "ui.newChannel" => Some("channel.new"), + "ui.mergeChannels" => Some("channel.merge"), + _ => None, + }; + if let Some(cmd) = asks { + if let Err(e) = crate::menus::invoke(app, &ctx, cmd, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + continue; + } if id == "ui.newSpotChannel" { crate::channel_options_ui::open_new_spot(app); continue; @@ -526,6 +541,91 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The engine reference of the targeted channel (`channel.*` and `select.loadSelection` take it). +fn target_reference(target: ChannelTarget) -> Value { + match target { + ChannelTarget::Alpha(i) => json!(i), + ChannelTarget::Color(k) => json!({ "color": k }), + ChannelTarget::Composite => json!("composite"), + ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::VectorMask => json!("vectorMask"), + } +} + +/// Whether the selected layer's mask row is the one shown targeted: the layer has a mask, and it +/// (not the composite) is what painting edits, outside Quick Mask. +fn mask_row_targeted(has_mask: bool, mask_target: bool, target: ChannelTarget, quick: bool) -> bool { + has_mask && (mask_target || target == ChannelTarget::LayerMask) && matches!(target, ChannelTarget::Composite | ChannelTarget::LayerMask) && !quick +} + +/// The layer mask row's name, " Mask", as Photoshop lists it. +fn mask_row_name(layer: &photocraft_doc::Layer) -> String { + format!("{} Mask", layer.name) +} + +/// The Channels panel's own items at the top of its panel menu (☰, #11451), in Photoshop's +/// order: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, +/// Channel Options…, Split Channels, Merge Channels…. The ones that act on one channel take the +/// targeted row and are greyed without one (a colour composite, several colour channels). New +/// Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs, as from +/// the command palette; Split Channels runs at once. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + use crate::layers_panel_ui::run_menu_command; + let target = MenuTarget::of(app); + run_menu_command(app, ui, tl!("New Channel…"), "channel.new", Some(json!({}))); + run_menu_command(app, ui, tl!("Duplicate Channel"), "channel.duplicate", target.duplicate); + run_menu_command(app, ui, tl!("Delete Channel"), "channel.delete", target.alpha.map(|i| json!({ "channel": i }))); + ui.separator(); + run_menu_command(app, ui, tl!("New Spot Channel…"), "channel.newSpot", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Spot Channel"), "channel.mergeSpot", target.spot.map(|i| json!({ "channel": i }))); + run_menu_command(app, ui, tl!("Channel Options…"), "channel.options", target.options.then(|| json!({}))); + ui.separator(); + run_menu_command(app, ui, tl!("Split Channels"), "channel.split", Some(json!({}))); + run_menu_command(app, ui, tl!("Merge Channels…"), "channel.merge", Some(json!({}))); +} + +/// What the panel menu's single-channel items act on: the targeted row. +#[derive(Default)] +struct MenuTarget { + /// The `channel.duplicate` params: the targeted row as its footer's New button copies it. + duplicate: Option, + /// The targeted alpha or spot channel. + alpha: Option, + /// The targeted spot channel. + spot: Option, + /// Whether Channel Options… has something to show (an alpha / spot channel, Quick Mask or a + /// layer mask), as the menu bar's command decides. + options: bool, +} + +impl MenuTarget { + fn of(app: &PhotocraftApp) -> Self { + let mask = app.layer_mask_targeted(); + let Some(st) = app.session.active() else { return Self::default() }; + let view = &st.channel_view; + let alpha = match view.target { + ChannelTarget::Alpha(i) if i < st.doc.channels.len() => Some(i), + _ => None, + }; + let quick = st.doc.quick_mask.is_some(); + let masked = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| l.mask.is_some()); + let duplicate = match view.target { + // The composite targeted in Quick Mask mode: the Quick Mask row is the selected one. + ChannelTarget::Composite if quick => Some(json!({ "channel": "quickMask" })), + _ if mask_row_targeted(masked.is_some(), app.ui.mask_target, view.target, quick) => { + masked.and_then(|layer| drop_action(Row::LayerMask, &mask_row_name(layer), Some(layer.id.0), Footer::New)).map(|(_, p)| p) + } + ChannelTarget::Alpha(_) if alpha.is_some() => Some(json!({ "channel": target_reference(view.target) })), + ChannelTarget::Color(_) if view.selected_colors.len() <= 1 => Some(json!({ "channel": target_reference(view.target) })), + // A one-channel image (Grayscale, Bitmap, Duotone): the composite is its channel. + ChannelTarget::Composite if channel_cmds::color_count(&st.doc) == 1 => Some(json!({ "channel": "composite" })), + _ => None, + }; + let spot = alpha.filter(|&i| st.doc.channels.get(i).is_some_and(|c| c.spot.is_some())); + Self { duplicate, alpha, spot, options: alpha.is_some() || quick || mask } + } +} + /// The footer buttons a row can be dropped on. #[derive(Clone, Copy)] enum Footer { @@ -575,7 +675,12 @@ pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) { } fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) { - if ui.button(tl!(&label)).clicked() { + item_enabled(ui, actions, label, cmd, p, true); +} + +/// [`item`], greyed unless `enabled`. +fn item_enabled(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value, enabled: bool) { + if ui.add_enabled(enabled, egui::Button::new(tl!(&label))).clicked() { actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 3d57762d03..123efd3ade 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -440,3 +440,212 @@ fn recording_a_colour_row_click_saves_as_photoshop_select() { assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); } } + +/// The panel menu's single-channel items act on the targeted channel (#11451): a targeted spot +/// channel can be duplicated, given options, merged or deleted; a colour channel duplicated. +#[test] +fn panel_menu_items_act_on_the_targeted_channel() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "fromSelection": false})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(!h.get_by_label(label).accesskit_node().is_disabled(), "{label} acts on the targeted spot channel"); + } + h.get_by_label("Channel Options…").click(); + h.run_steps(2); + assert!(h.state().ui.dialogs.iter().any(|d| crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!(0))); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink", "Ink copy"]); + h.get_by_label("Delete Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy"], "the targeted channel is deleted"); + h.state_mut().run("channel.target", json!({"channel": {"color": 1}})).unwrap(); + h.run_steps(2); + assert!(h.get_by_label("Delete Channel").accesskit_node().is_disabled(), "colour channels aren't deleted here"); + h.get_by_label("Duplicate Channel").click(); + h.run_steps(2); + assert_eq!(alpha_names(h.state()), ["Ink copy", "Green copy"]); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +fn alpha_names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() +} + +fn menu_app(mode: &str) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24, "mode": mode})).unwrap(); + app +} + +fn menu_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h +} + +fn menu_disabled(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + use egui_kittest::kittest::{NodeT, Queryable}; + h.get_by_label(label).accesskit_node().is_disabled() +} + +fn menu_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, label: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_label(label).click(); + h.run_steps(2); + assert!(!h.state().ui.status_error, "{label}: {}", h.state().ui.status); +} + +/// A grayscale image's composite is its one channel: Duplicate Channel copies it, as the row +/// menu does; an RGB composite stays greyed. +#[test] +fn panel_menu_duplicates_the_gray_composite() { + assert!(menu_disabled(&menu_harness(menu_app("rgb")), "Duplicate Channel"), "nothing to duplicate on the RGB composite"); + let mut h = menu_harness(menu_app("grayscale")); + assert!(!menu_disabled(&h, "Duplicate Channel")); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Gray copy"]); +} + +/// Duplicate Channel copies a targeted Quick Mask or layer mask, as dropping the row on New does +/// (the mask's copy is " Mask copy"). +#[test] +fn panel_menu_duplicates_quick_mask_and_layer_mask() { + let mut app = menu_app("rgb"); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the Quick Mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(h.state().session.active().unwrap().doc.channels.len(), 1); + + let mut app = menu_app("rgb"); + app.run("layer.new.layer", json!({"name": "Masked"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"channel": "mask"})).unwrap(); + let mut h = menu_harness(app); + assert!(!menu_disabled(&h, "Duplicate Channel"), "the layer mask can be duplicated"); + menu_click(&mut h, "Duplicate Channel"); + assert_eq!(alpha_names(h.state()), ["Masked Mask copy"]); +} + +/// In Quick Mask mode, Channel Options… on a targeted alpha channel opens that channel's options, +/// from the panel menu and the command palette (`menus::invoke`) alike; with the composite +/// targeted it is Quick Mask's. +#[test] +fn channel_options_in_quick_mask_take_the_targeted_alpha() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.editInQuickMaskMode", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!("quickMask"), "the composite targeted: Quick Mask Options"); + app.ui.dialogs.clear(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "channel.options", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["channel"], json!(0), "the dispatcher opens the alpha's options"); + app.ui.dialogs.clear(); + let mut h = menu_harness(app); + menu_click(&mut h, "Channel Options…"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["channel"], json!(0), "so does the panel menu"); +} + +/// Duplicate Channel waits for one channel: not several colour channels at once. Without a +/// document every item is greyed. +#[test] +fn panel_menu_greys_items_without_one_channel_or_a_document() { + let mut app = menu_app("rgb"); + app.run("channel.target", json!({"channel": {"color": 0}})).unwrap(); + app.run("channel.target", json!({"channel": {"color": 1}, "extend": true})).unwrap(); + assert_eq!(app.session.active().unwrap().channel_view.selected_colors.len(), 2); + assert!(menu_disabled(&menu_harness(app), "Duplicate Channel"), "two colour channels are targeted"); + let h = menu_harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for label in [ + "New Channel…", + "Duplicate Channel", + "Delete Channel", + "New Spot Channel…", + "Merge Spot Channel", + "Channel Options…", + "Split Channels", + "Merge Channels…", + ] { + assert!(menu_disabled(&h, label), "{label} waits for a document"); + } +} + +/// Merge Spot Channel prints the targeted spot channel into the image and removes it: a solid +/// blue ink over the left half turns those pixels blue and leaves the rest white. +#[test] +fn panel_menu_merges_the_targeted_spot_channel() { + let mut app = menu_app("rgb"); + app.run("channel.new", json!({"name": "Alpha 1"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); + app.run("channel.newSpot", json!({"name": "Ink", "color": "#0000ff", "solidity": 100})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("channel.target", json!({"channel": 1})).unwrap(); + let pixel = |app: &PhotocraftApp, x: usize, y: usize| { + let doc = &app.session.active().unwrap().doc; + let comp = photocraft_compose::render(doc, doc.bounds()); + let p = comp.px[y * doc.size.width as usize + x]; + [p[0], p[1], p[2]].map(|v| (v * 255.0).round() as u8) + }; + assert_eq!((pixel(&app, 4, 4), pixel(&app, 24, 4)), ([255, 255, 255], [255, 255, 255]), "the ink is not in the image yet"); + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Spot Channel"); + assert_eq!(alpha_names(h.state()), ["Alpha 1"], "the spot channel is merged, the alpha kept"); + assert_eq!(pixel(h.state(), 4, 4), [0, 0, 255], "the ink is printed where the channel had it"); + assert_eq!(pixel(h.state(), 24, 4), [255, 255, 255], "and nowhere else"); +} + +/// Merge Channels… asks first, as in Photoshop: the dialog picks the mode and the document for +/// each channel; nothing merges until OK. With params the command runs directly. +#[test] +fn panel_menu_merge_channels_opens_its_dialog() { + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let mut h = menu_harness(app); + menu_click(&mut h, "Merge Channels…"); + assert_eq!(h.state().session.documents().len(), 3, "nothing is merged before OK"); + let d = h.state().ui.dialogs.last().expect("the Merge Channels dialog"); + assert_eq!(d.fields["__label"], json!("Merge Channels…")); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("mode".into(), json!("lab")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 1, "the sources close"); + assert_eq!(docs[0].doc.mode, photocraft_doc::ColorMode::Lab, "in the mode picked"); + + let ctx = egui::Context::default(); + let mut app = menu_app("grayscale"); + for _ in 0..2 { + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + } + let r = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap(); + assert!(r["dialog"].is_u64() && app.session.documents().len() == 3, "the dispatcher asks too"); + app.ui.dialogs.clear(); + crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({"mode": "rgb"})).unwrap(); + assert!(app.ui.dialogs.is_empty() && app.session.documents().len() == 1, "explicit params merge directly"); +} + +/// Two grayscale documents fill no mode: Merge Channels… is greyed in the panel menu, and the +/// dispatcher says why instead of opening the dialog; a third one enables it. +#[test] +fn merge_channels_waits_for_three_grayscale_documents() { + let mut app = menu_app("grayscale"); + app.run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + let ctx = egui::Context::default(); + let why = crate::menus::invoke(&mut app, &ctx, "channel.merge", json!({})).unwrap_err(); + assert!(why.contains("three") && app.ui.dialogs.is_empty(), "{why}"); + let mut h = menu_harness(app); + assert!(menu_disabled(&h, "Merge Channels…"), "two documents are not enough"); + h.state_mut().run("file.new", json!({"width": 32, "height": 24, "mode": "grayscale"})).unwrap(); + h.run_steps(2); + assert!(!menu_disabled(&h, "Merge Channels…"), "three are"); +} diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 939ef07e6b..aad55b96d2 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -160,11 +160,11 @@ pub(super) fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ctx.input(|i| i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(history_drag_key())); } - // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under - // its box would leave it transforming pixels that changed. if snapshot_dialog { crate::snapshot_dialog::open(app); } + // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under + // its box would leave it transforming pixels that changed. // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); if let Some((cmd, p)) = footer_cmd.filter(|_| app.ui.transform.is_none()).or(snap_cmd) { @@ -253,17 +253,18 @@ pub(crate) fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::Doc live.then_some(id) } -/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and -/// name: a click returns to the snapshot (a new state) and selects it, a double-click or -/// right-click › Rename… edits the name in place (Enter or a click elsewhere keeps it, Escape -/// cancels), right-click › Delete removes it, and a drag onto the trash too (#5324). Returns the -/// command a row asks for. +/// The History panel's snapshot rows (#2966), at the top of the panel (the first is the document +/// as opened), each with its thumbnail and name: a click returns to the snapshot (a new state) +/// and selects it, a double-click or right-click › Rename… edits the name in place (Enter or a +/// click elsewhere keeps it, Escape cancels), right-click › Delete removes it, and a drag onto the +/// trash too (#5324). Returns the command a row asks for. fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option) -> Option<(&'static str, Value)> { let t = Tokens::get(ui.ctx()); let st = app.session.active()?; let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); let source = photocraft_engine::history_source_cmds::source(st); let live: Vec = snaps.iter().map(|s| s.id).collect(); + app.retain_snapshot_thumbs(doc_id, &live); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. let rename_id = egui::Id::unique(("history-snapshot-rename", doc_id)); let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); @@ -283,7 +284,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option bool { if cmd == "window.panel.close" { return !app.session.prefs().workspace_locked; } - // An open Free Transform owns the history (see `panels::history`). - app.ui.transform.is_none() && app.session.is_enabled(cmd) + // An open Free Transform owns the history (see `panels::history`), and a drawn crop frame + // holds its jumps back (`crop_ui::holds_history`). + app.ui.transform.is_none() && !crate::crop_ui::holds_history(app, cmd) && app.session.is_enabled(cmd) } /// Draw the History items of the panel menu and run the one clicked. @@ -158,6 +159,26 @@ mod tests { } } + /// A drawn crop frame greys the items that move the document under it (#9285). + #[test] + fn jumps_wait_for_a_drawn_crop_frame() { + let mut app = app(); + app.ui.tool = crate::state::Tool::Crop; + app.ui.crop_rect = Some([4.0, 4.0, 40.0, 30.0]); + app.crop.default_frame = false; + app.session.execute("history.stepBackward", json!({})).unwrap(); + let mut h = harness(app); + for label in ["Step Backward", "Step Forward", "Delete"] { + assert!(is_disabled(&h, label), "{label}"); + } + assert!(!is_disabled(&h, "New Snapshot…"), "a snapshot leaves the document as it is"); + h.state_mut().crop.default_frame = true; + h.run_steps(2); + assert!(!is_disabled(&h, "Step Backward"), "the tool's own frame around the canvas holds nothing back"); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (2, 2)); + } + #[test] fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { let mut h = harness(app()); diff --git a/crates/ui-egui/src/panels/layer_row_ui.rs b/crates/ui-egui/src/panels/layer_row_ui.rs index 82d924bacf..060b12a223 100644 --- a/crates/ui-egui/src/panels/layer_row_ui.rs +++ b/crates/ui-egui/src/panels/layer_row_ui.rs @@ -277,21 +277,21 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } } ui.separator(); - run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage", Some(json!({}))); ui.separator(); if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); ui.close(); } - run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer", Some(json!({}))); let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); if any_hidden { - run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers", Some(json!({}))); } else { - run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers", Some(json!({}))); } ui.separator(); - run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard", Some(json!({}))); ui.separator(); ui.menu_button(tl!("Filter Options"), |ui| { let mut show = app.ui.layers_panel_options.show_filters; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 835b370af2..7dfa2ad6cd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -4,7 +4,9 @@ use super::layer_drag::{ }; use super::layer_row::{RowSel, layer_row}; use super::options_bar::{LAYER_PCT_W, body_text_width, pct_action, scrub_pct_label}; -use super::{BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, theme, vec2, widgets}; +use super::{ + BlendMode, Layer, LayerId, MaskKind, PhotocraftApp, Rect, Sense, Tokens, Value, empty, icons, json, label, pos2, starts_blend_group, theme, vec2, widgets, +}; // ----------------------------------------------------------------------------- layers @@ -58,7 +60,7 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let mut m = BlendMode::Normal; let options = blend_options(false, m); - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width, |m| starts_blend_group(*m)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = 100.0; widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -154,7 +156,9 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let (chosen, hovered) = - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width); + widgets::dropdown_wheel_grouped(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width, |m| { + starts_blend_group(*m) + }); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { let mut p = targets.clone(); diff --git a/crates/ui-egui/src/panels/layers_panel_ui.rs b/crates/ui-egui/src/panels/layers_panel_ui.rs index d640e3850d..5bf63ac814 100644 --- a/crates/ui-egui/src/panels/layers_panel_ui.rs +++ b/crates/ui-egui/src/panels/layers_panel_ui.rs @@ -27,11 +27,17 @@ pub fn restore_options(app: &mut PhotocraftApp) { } } -/// The real menu command dispatcher handles rename's in-place editor and other shell routing. -pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { - if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { +/// A panel-menu item running menu command `id` with `params` (`{}` for none) through the real +/// menu command dispatcher, which handles rename's in-place editor, opens a command's dialog where +/// it has one and does other shell routing. Greyed when `params` is `None` (nothing to act on) or +/// where the menu bar greys the command. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str, params: Option) { + let on = params.is_some() && crate::menus::is_enabled(app, id); + if ui.add_enabled(on, egui::Button::new(label)).clicked() + && let Some(params) = params + { let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 224e399554..15c602ae99 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -1,6 +1,6 @@ use super::brush_controls::{brush_preset_chip, mixer_load_swatch, paint_mode_dropdown, smoothing_field, smoothing_options, tool_mode_dropdown}; use super::properties::{select_and_mask_button, selection_mode_buttons}; -use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, vec2, widgets}; +use super::{BlendMode, Color32, PhotocraftApp, RichText, Sense, Tokens, Tool, Value, icons, json, starts_blend_group, vec2, widgets}; use photocraft_paint::BrushSettings; // Custom controls also need to bring keyboard focus into a scroll viewport. @@ -449,7 +449,9 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); // The wheel over it steps the mode, as over the Layers panel's (#2061). - widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + widgets::dropdown_wheel_grouped(ui, "gradient-blend-mode", tl!("Blend Mode"), &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0, |m| { + starts_blend_group(*m) + }); opt_label(ui, tl!("Opacity")); let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); // Live while scrubbed, one undo step per scrub (#5012). @@ -474,9 +476,11 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: // Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919). crate::crop_presets::dropdown(app, ui); let o = &mut app.ui.tool_options; + let mut turn = false; let mut ratio_base = None; if o.crop_ratio == crate::crop_size::WHR { - crate::crop_size::fields(o, ui); + // ⇄ turns the frame with W and H, as with a ratio. + turn = crate::crop_size::fields(o, ui); } else { // Keep partial edits in ToolOptions so the first side survives a frame; // Original Ratio shows the document's numbers, as Photoshop does. @@ -488,6 +492,7 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: let (cw, ch) = (fw.changed(), fh.changed()); if swap { std::mem::swap(&mut rw, &mut rh); + turn = true; } if cw || ch || swap { crate::crop_size::set_custom_ratio_fields(o, rw, rh); @@ -526,6 +531,9 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: &[(false, crate::crop_ui::fill_label(delete_cropped_pixels)), (true, tl!("Content-Aware Fill"))], 150.0, ); + if turn { + crate::crop_ui::turn_frame(app); + } if crate::crop_ui::preset_ratio(app) != before_ratio { match ratio_base { Some(base) => crate::crop_ui::apply_ratio_from(app, base), diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index ebddecde2c..6029ae52b4 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -1206,6 +1206,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::histogram_panel::panel_menu(app, ui); ui.separator(); } + "channels" => { + crate::channels_panel::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index fb2fb9bd79..886e6f0f8a 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -763,6 +763,46 @@ fn actions_panel_menu_includes_load_and_save_actions() { assert!(menu.query_all_by_label("New Action…").count() > 0); } +/// The Channels panel menu (☰) has the channel items, not only the rows' context menu (#11451): +/// New Spot Channel… and New Channel… open their dialogs, and OK in New Channel adds a channel. +#[test] +fn channels_panel_menu_opens_new_spot_and_adds_a_channel() { + use egui_kittest::kittest::NodeT; + let mut app = self::app(); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut ready = false; + let mut menu = Harness::builder().with_size(vec2(260.0, 420.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "channels"); + }, + app, + ); + menu.run_steps(2); + let disabled = |menu: &Harness<'static, PhotocraftApp>, label: &str| menu.get_by_label(label).accesskit_node().is_disabled(); + // The composite is targeted: nothing to duplicate, delete or give options to. + for label in ["Duplicate Channel", "Delete Channel", "Merge Spot Channel", "Channel Options…"] { + assert!(disabled(&menu, label), "{label} waits for a targeted channel"); + } + menu.get_by_label("New Spot Channel…").click(); + menu.run_steps(2); + let dialogs = &menu.state().ui.dialogs; + assert!(dialogs.iter().any(|d| crate::channel_options_ui::owns_new_spot(&d.fields)), "New Spot Channel… opens its dialog"); + menu.state_mut().ui.dialogs.clear(); + menu.get_by_label("New Channel…").click(); + menu.run_steps(2); + assert!(menu.state().session.active().unwrap().doc.channels.is_empty(), "New Channel… asks first"); + let dialog = menu.state().ui.dialogs.last().expect("the New Channel dialog"); + assert_eq!(dialog.fields["__label"], json!("New Channel…")); + let id = dialog.id; + crate::dialogs::confirm(menu.state_mut(), id).unwrap(); + assert_eq!(menu.state().session.active().unwrap().doc.channels.len(), 1, "OK adds an alpha channel"); + assert!(!menu.state().ui.status_error, "{}", menu.state().ui.status); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); diff --git a/crates/ui-egui/src/render_path.rs b/crates/ui-egui/src/render_path.rs index fe42e50667..9aae3cdb6d 100644 --- a/crates/ui-egui/src/render_path.rs +++ b/crates/ui-egui/src/render_path.rs @@ -32,6 +32,8 @@ pub enum CpuReason { CanvasTooLarge { bytes: u64, budget: u64 }, /// The driver couldn't build the compositor's pipelines (such as DX12 FXC X3507, #8733). CompositorBuild(String), + /// The GPU compositor is being built in the background. + CompositorBuilding, /// The device was lost (the fault's text). DeviceLost(String), /// An uncaptured GPU error (the fault's text). @@ -61,6 +63,7 @@ impl CpuReason { CpuReason::OverBudget { .. } => "overBudget", CpuReason::CanvasTooLarge { .. } => "canvasTooLarge", CpuReason::CompositorBuild(_) => "compositorBuild", + CpuReason::CompositorBuilding => "compositorBuilding", CpuReason::DeviceLost(_) => "deviceLost", CpuReason::GpuError(_) => "gpuError", CpuReason::Unsupported(_) => "unsupported", @@ -87,6 +90,7 @@ impl fmt::Display for CpuReason { CpuReason::CanvasTooLarge { bytes, budget } => { write!(f, "the canvas needs {} of GPU textures, budget {}; drawing it on the CPU canvas", fmt_bytes(*bytes), fmt_bytes(*budget)) } + CpuReason::CompositorBuilding => f.write_str(tl!("The GPU compositor is still being prepared")), CpuReason::CompositorBuild(e) => write!(f, "the GPU compositor couldn't be built: {e}"), CpuReason::DeviceLost(e) | CpuReason::GpuError(e) | CpuReason::Unsupported(e) => f.write_str(e), CpuReason::AdjustPreview(why) => write!(f, "the adjustment preview uses the CPU proxy: {why}"), @@ -137,6 +141,9 @@ pub struct RenderPath { } impl RenderPath { + pub(crate) fn compositor_building_docs(&self) -> Vec { + self.docs.iter().filter_map(|(&doc, reason)| matches!(reason, CpuReason::CompositorBuilding).then_some(doc)).collect() + } /// Why document `doc` is drawn on the CPU now, or None on the GPU: the window's reason, else /// the view's, else the canvas's, else the adjustment preview's. A software adapter still runs /// the GPU canvas, so a document that left even that names its own reason first. diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 7f05fdc1de..8224b28698 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -331,6 +331,8 @@ pub struct ExternalVectorSource { pub type OpenExternalSvgFn = Box Result, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; +/// Shared queue of browser canvas drops with the document captured at the drop event. +pub type PlaceInbox = std::sync::Arc)>>>; /// Read the saved preferences text (`None` when there is none yet). pub type LoadTextFn = Box Option>; @@ -461,7 +463,7 @@ pub struct Services { /// Files delivered asynchronously for a drop onto the active document's canvas (web /// drag-and-drop, routed by [`PhotocraftApp::dropped_on_canvas`]): placed as layers like a /// desktop canvas drop (Place Embedded, each in Free Transform). - pub place_inbox: Option, + pub place_inbox: Option, /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, @@ -475,6 +477,11 @@ pub struct Services { /// the web (see `prefs_ui`). pub load_prefs: Option, pub save_prefs: Option, + /// Load/save the bounded deterministic GPU compositor failure memo (native only). + pub load_gpu_memo: Option, + pub save_gpu_memo: Option, + /// Compiler identity included in GPU memo keys (for example `fxc` or `dxc:`). + pub shader_compiler: Option, /// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`). pub photoshop_shortcuts: Option, pub system_theme: Option, diff --git a/crates/ui-egui/src/text/served_fonts.rs b/crates/ui-egui/src/text/served_fonts.rs index e457fc3bf2..57ce3264f8 100644 --- a/crates/ui-egui/src/text/served_fonts.rs +++ b/crates/ui-egui/src/text/served_fonts.rs @@ -9,13 +9,14 @@ use std::sync::{Arc, Mutex, PoisonError}; use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; use egui::{FontData, FontFamily}; -use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; +use photocraft_doc::{LayerContent, TextLayer}; +use photocraft_engine::TypeLayerRef; pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; /// Layers whose document was busy with a background job when their font arrived. -static PENDING: Mutex> = Mutex::new(Vec::new()); +static PENDING: Mutex> = Mutex::new(Vec::new()); /// Served script fallbacks in the UI's font stack, by egui font name, unscaled: kept so the stack /// `theme::install_fonts` rebuilds on a language change includes them again. @@ -89,17 +90,7 @@ pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(Str let families: Vec = delivered.iter().map(|(f, _)| f.clone()).collect(); // Decided before the fonts are in: "shows our own layout" compares the layer's pixels // with what the engine draws now, that is with the fallback font. - for st in app.session.documents() { - for (_, _, l) in st.doc.walk() { - if let LayerContent::Text(t) = &l.content - && redraws_for(t, &families) - && crate::type_tool::shows_own_layout(&st.doc, t) - && !layers.contains(&(st.doc.id, l.id)) - { - layers.push((st.doc.id, l.id)); - } - } - } + pick(&app.session, &families, &mut layers); let fallbacks = photocraft_text::served::script_fallback_families(); for (family, bytes) in &delivered { if fallbacks.contains(family) { @@ -120,6 +111,36 @@ pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(Str } } +/// Adds to `layers` the type layers to redraw once `families` arrive, in every open document and +/// in its first History snapshot (the document as opened), which is checked on its own: since +/// then its layers may have been edited (a PSD layer re-rendered) or deleted (#6087). +fn pick(session: &photocraft_engine::Session, families: &[String], layers: &mut Vec) { + for st in session.documents() { + let first = st.snapshots.iter().find(|s| s.id == 0).map(|s| &s.doc); + // While it still is the open document, a layer picked there is picked in the snapshot too + // (without checking it twice): a busy document is refreshed later, and by then a finished + // job can have moved the open document on. + let shared = first.is_some_and(|f| Arc::ptr_eq(f, &st.doc)); + let first = first.filter(|_| !shared); + for (doc, first_snapshot) in std::iter::once((&st.doc, false)).chain(first.map(|d| (d, true))) { + for (_, _, l) in doc.walk() { + if let LayerContent::Text(t) = &l.content + && redraws_for(t, families) + && crate::type_tool::shows_own_layout(doc, t) + { + let also = (shared && !first_snapshot).then_some(true); + for first_snapshot in std::iter::once(first_snapshot).chain(also) { + let key = TypeLayerRef { doc: st.doc.id, layer: l.id, first_snapshot }; + if !layers.contains(&key) { + layers.push(key); + } + } + } + } + } + } +} + /// Should the layer be redrawn when `families` arrive: it uses one of them, or its text is in a /// script one of them is the served fallback for? fn redraws_for(t: &TextLayer, families: &[String]) -> bool { @@ -151,6 +172,34 @@ mod tests { assert!(uses_family(&psd, &["Lato".into()])); } + /// #6087: the first History snapshot is picked on its own, so a type layer deleted since the + /// document opened still gets the font there, and one still shared with the open document is + /// picked in both without being checked twice. + #[test] + fn the_first_snapshot_is_picked_on_its_own() { + use photocraft_doc::LayerId; + use serde_json::json; + let family = "Served Test Pick"; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24, "font": family})).unwrap()["layer"].as_u64().unwrap()); + let doc = (*s.active().unwrap().doc).clone(); + s.add_document(doc, None); + let doc_id = s.active().unwrap().doc.id; + let key = |first_snapshot| TypeLayerRef { doc: doc_id, layer: id, first_snapshot }; + let picked = |s: &photocraft_engine::Session| { + let mut layers = Vec::new(); + pick(s, &[family.into()], &mut layers); + layers.retain(|l| l.doc == doc_id); + layers + }; + assert_eq!(picked(&s), [key(false), key(true)], "the snapshot still is the open document"); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(picked(&s), [key(false), key(true)]); + s.execute("layer.delete", json!({"layer": id.0})).unwrap(); + assert_eq!(picked(&s), [key(true)], "deleted from the open document, still in the snapshot"); + } + #[test] fn a_layer_in_a_script_redraws_when_that_scripts_fallback_arrives() { // Thai: other tests use other scripts (the served catalog is process-wide). diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 4401e9b15d..c0afceb0d1 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -458,6 +458,33 @@ fn commit_is_the_same_in_both_modes_and_the_view_stays_upright() { assert!(out[0].1 == out[1].1, "same pixels"); } +/// #9202: switching documents drops a turned pending crop and turns its own document's view +/// upright, as Esc would, not only the view of the document switched to. +#[test] +fn switching_documents_turns_the_dropped_crops_view_upright() { + // The crop on the first document, then on the second. + for (on, other) in [(0, 1), (1, 0)] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + app.sync_views(); + assert!(app.session.set_active(on)); + app.sync_views(); + let v = &mut app.ui.views[on]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0); + with_frame(&mut app); + screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE); + assert!((view_rotation(&app) - 90.0).abs() < 0.01, "crop on {on}: the view turned with the frame"); + assert!(app.session.set_active(other)); + app.sync_views(); + assert_eq!(app.ui.crop_rect, None, "crop on {on}: the turned crop is dropped"); + app.ui.tool = Tool::Brush; + crate::crop_ui::ensure_frame(&mut app); + assert!(app.session.set_active(on)); + app.sync_views(); + assert_eq!(view_rotation(&app), 0.0, "crop on {on}: its document isn't left turned"); + } +} + #[test] fn picking_the_crop_tool_turns_a_rotated_view_upright() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index c9ce39f83d..3709ec6767 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -315,11 +315,15 @@ fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width } /// The W x H x Resolution fields of the options bar: W, ⇄, H, the resolution and its unit. -pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { +/// Returns true when ⇄ swapped W and H, so the Crop tool turns its frame with them. +pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) -> bool { text_field(ui, "crop-width", &mut o.crop_width, tl!("Width"), 64.0, normalize_length); - if crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { + // Always swaps (Perspective Crop shows the fields without the mode); true in W x H x Resolution + // mode, where the Crop tool turns its frame with W and H. + let swapped = crate::icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() && { swap_fields(o); - } + o.crop_ratio == WHR + }; text_field(ui, "crop-height", &mut o.crop_height, tl!("Height"), 64.0, normalize_length); text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution); let units = [(PX_PER_IN.to_string(), "px/in"), (PX_PER_CM.to_string(), "px/cm")]; @@ -331,6 +335,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { o.crop_resolution = crate::widgets::fmt_num2(v); } } + swapped } #[cfg(test)] diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 890a2b00de..490be07fa5 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -499,6 +499,29 @@ pub fn active(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_some() } +/// A drag that sizes or moves the frame over the image, which scrolls the view when held past the +/// canvas edge (#4183). Not the turns, nor the default mode's image moves and live-centred resizes: +/// those drive the camera themselves. A new frame's drag that hasn't left the size of a click (a +/// long press that slipped past the edge) doesn't either: scrolling would draw a frame under the +/// still pointer. +pub fn autoscrolls(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::Crop + && match app.crop.drag { + Some(CropDrag::Draw { anchor, cur, .. }) => !is_click(app, anchor, cur), + Some(CropDrag::Move { .. } | CropDrag::Resize { view: None, .. }) => true, + _ => false, + } +} + +/// A new-frame drag shorter than this (screen points) on both axes is still a click. +const CLICK_PX: f64 = 3.0; + +/// Document points `a` and `b` lie within [`CLICK_PX`] screen points of each other on both axes. +fn is_click(app: &PhotocraftApp, a: [f64; 2], b: [f64; 2]) -> bool { + let click = CLICK_PX / (app.point_zoom() as f64).max(0.01); + (b[0] - a[0]).abs() <= click && (b[1] - a[1]).abs() <= click +} + /// Cancel the pending crop: the frame, any gesture on it and the default-frame state. With the /// Crop tool still picked, [`ensure_frame`] then makes the active document a new default frame. pub fn cancel(app: &mut PhotocraftApp) { @@ -653,6 +676,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Intercept Undo/Redo/Toggle Last State while the Crop tool owns box history, for every caller /// (menus, shortcuts, the control channel, MCP) — as `transform_tool::intercept` does. pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { + if holds_history(app, id) { + return Some(Err("Commit or cancel the crop first".into())); + } if app.ui.tool != Tool::Crop || (app.crop.undo.is_empty() && app.crop.redo.is_empty()) { return None; } @@ -664,6 +690,13 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option bool { + pending(app) && matches!(id, "history.selectState" | "history.selectSnapshot" | "history.stepBackward" | "history.stepForward" | "history.deleteState") +} + /// Undo (or redo) one box step. fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { let s = if redo { app.crop.redo.pop() } else { app.crop.undo.pop() }; @@ -750,6 +783,16 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { let pending = app.ui.crop_rect.is_some() || app.crop.drag.is_some(); // A frame made before any document was claimed (control channel, tests) is the active one's. if pending && app.crop.doc.is_some() && app.crop.doc != active { + // Default mode turned the frame's own view, which `cancel` doesn't reach: it turns the + // active one upright, and `ui.views` is still in the order of `view_docs` here (#9202). + if !crate::crop_mode::classic(app) { + let doc = app.crop.doc; + let synced = app.view_docs.iter().position(|d| Some(*d) == doc); + let i = synced.or_else(|| app.session.documents().iter().position(|st| Some(st.doc.id) == doc)); + if let Some(v) = i.and_then(|i| app.ui.views.get_mut(i)) { + v.rotation = 0.0; + } + } cancel(app); } app.crop.doc = active; @@ -884,6 +927,18 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { [c[0] - hh, c[1] - hw, c[0] + hh, c[1] + hw] } +/// The options bar's ⇄ with a ratio set: the frame turns with the ratio about its centre, as X +/// does, so fitting the swapped ratio afterwards doesn't shrink it (#7738). +pub(crate) fn turn_frame(app: &mut PhotocraftApp) { + // A square frame doesn't change, so an untouched default frame stays one (and isn't + // committed on leaving the tool). + if let Some(r) = app.ui.crop_rect.filter(|_| takes_keys(app) && preset_ratio(app).is_some()) + && swapped(r) != r + { + keyed(app, swapped(r)); + } +} + /// The options-bar ratio `key` with its sides swapped (what the bar's ⇄ button does), `None` when /// no ratio is set; "original" becomes the document's size turned on its side. pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { @@ -903,8 +958,11 @@ pub fn swap_orientation(app: &mut PhotocraftApp) -> bool { { app.ui.tool_options.crop_ratio = k; } - // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. - keyed(app, swapped(r)); + // The frame turns first: fitting the swapped ratio inside the old frame would shrink it. A + // square frame doesn't change, so an untouched default frame stays one. + if swapped(r) != r { + keyed(app, swapped(r)); + } if preset_ratio(app).is_some() { apply_ratio(app); } else { @@ -937,7 +995,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// Pointer input for the Crop tool. Returns true when the event was its. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if app.ui.tool != Tool::Crop { - app.crop.drag = None; + end_drag(app); return false; } let p = match ev { @@ -1045,7 +1103,9 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { let ok = app.ui.crop_rect.is_some_and(size_ok); // A click outside the untouched default frame (no turn) leaves it the default. let changed = !matches!(drag, CropDrag::Rotate { from, .. } | CropDrag::TurnImage { from, .. } if from == angle(app)) - && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)); + && !matches!(drag, CropDrag::MoveImage { rect, .. } if app.ui.crop_rect == Some(rect)) + // A press that never moved draws nothing: the frame shown is still the previous one. + && !matches!(drag, CropDrag::Draw { anchor, cur, .. } if anchor == cur); if ok && changed { app.crop.default_frame = false; } @@ -1057,6 +1117,25 @@ fn release(app: &mut PhotocraftApp, drag: CropDrag) { push_press_step(app); } +/// Another tool was picked while a drag on the frame is held (a tool key, #9223): the gesture ends +/// where the pointer last moved it, as a release there would, so a box drawn over the untouched +/// default frame counts as drawn and leaving the tool commits it. +pub fn end_drag(app: &mut PhotocraftApp) { + let Some(drag) = app.crop.drag.take() else { return }; + let tool = std::mem::replace(&mut app.ui.tool, Tool::Crop); + release(app, drag); + crate::crop_mode::sync_frame(app); + app.ui.tool = tool; +} + +/// [`end_drag`] once another tool is current, except while the pen's eraser end stands in for the +/// Crop tool (`stylus`): the gesture goes on when the tip comes back. +pub fn end_drag_on_leave(app: &mut PhotocraftApp) { + if app.ui.tool != Tool::Crop && app.stylus.tool_before_eraser != Some(Tool::Crop) { + end_drag(app); + } +} + /// On release: the frame state at the press becomes a box Undo step when the gesture changed it. fn push_press_step(app: &mut PhotocraftApp) { if let Some(before) = app.crop.press_step.take() diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 8281cd7ca4..93e2de2556 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -496,6 +496,27 @@ fn moving_the_frame_snaps_its_edges() { assert_eq!(app.ui.crop_rect, Some([0.0, 10.0, 40.0, 40.0]), "left edge snaps to the document edge"); } +/// A click on the frame (released where it was pressed) changes nothing, as a Move-tool click +/// doesn't move the layer: snapping the release would pull an edge within reach onto the +/// document's, so a double-click to commit cropped another rect. +#[test] +fn a_click_on_the_frame_or_a_handle_never_snaps_it() { + let frame = [5.0, 10.0, 105.0, 60.0]; + for classic in [true, false] { + // Inside (Classic: moves the frame; default: moves the image), the left edge's handle, the top-left corner's. + for p in [[50.0, 30.0], [5.0, 35.0], [5.0, 10.0]] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.extras.snap = true; + app.ui.crop_rect = Some(frame); + app.crop.default_frame = false; + drag(&mut app, &[p], NONE); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}, click at {p:?}"); + assert!(!crate::menus::is_enabled(&app, "edit.undo"), "classic {classic}, click at {p:?}: no box step"); + } + } +} + /// #3145: Edit › Undo while a crop is pending steps the *box*, not the document (Photoshop keeps /// Undo live during a crop), so a moved frame comes back. #[test] @@ -532,6 +553,41 @@ fn box_undo_and_redo_grey_out_when_their_stack_is_empty() { assert!(crate::menus::is_enabled(&app, "edit.redo")); } +/// A History jump under a drawn frame would resize the document while the frame keeps its +/// coordinates, so Enter cropped the old ones (#9285): the panel's rows, snapshots and menu +/// wait for the crop, as they do for Free Transform. +#[test] +fn history_jumps_wait_for_a_pending_crop() { + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + let mut app = app(SampleType::U8); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("image.canvasSize", json!({"width": 300, "height": 200})).unwrap(); + let snapshot = app.session.active().unwrap().snapshots[0].id; + app.ui.crop_rect = Some([220.0, 120.0, 290.0, 190.0]); + app.crop.default_frame = false; + for (cmd, p) in [ + ("history.selectState", json!({"state": 0})), + ("history.selectSnapshot", json!({"snapshot": snapshot})), + ("history.stepBackward", json!({})), + ("history.deleteState", json!({})), + ] { + assert!(app.run(cmd, p).is_err(), "{cmd} waits for the crop"); + assert_eq!(size(&app), Some((300, 200)), "{cmd} left the document as it was"); + } + assert_eq!(app.ui.crop_rect, Some([220.0, 120.0, 290.0, 190.0]), "the frame stays"); + // Without a drawn frame (the tool's own frame around the canvas) History works as before. + app.crop.default_frame = true; + app.run("history.selectState", json!({"state": 0})).unwrap(); + assert_eq!(size(&app), Some((200, 100))); + // Step Forward has a state to redo now; a drawn frame holds it back too. + app.crop.default_frame = false; + assert!(app.run("history.stepForward", json!({})).is_err(), "history.stepForward waits for the crop"); + assert_eq!(size(&app), Some((200, 100))); + app.crop.default_frame = true; + app.run("history.stepForward", json!({})).unwrap(); + assert_eq!(size(&app), Some((300, 200))); +} + #[test] fn handles_resize_with_shift_and_alt() { let mut app = app(SampleType::U8); @@ -665,6 +721,226 @@ fn space_on_the_canvas_moves_the_frame_not_the_view() { assert!(st.crop.drag.is_none()); } +/// Real egui input: a handle dragged past the canvas edge and held there scrolls the view, as a +/// marquee drag does (#4183), so the frame can reach the part of the image that was off screen. +#[test] +fn a_handle_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The right edge's middle handle: document (100, 25). + let p0 = h.state().ui.views[0].center; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: p0, flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 25.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Resize { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[2] > 175.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(out, false)); + h.step(); +} + +/// A press that started off the canvas (a panel slider beside it) doesn't scroll the view, +/// even with a crop drag still recorded: only a drag on the canvas auto-scrolls. +#[test] +fn a_press_beside_the_canvas_does_not_scroll_it_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.ui.extras.rulers = false; + // Left over from a drag a tool switch didn't end. + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The canvas takes the left 400 points; the rest stands in for a panel. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p = pos2(500.0, 100.0); + h.event(Event::PointerMoved(p)); + h.event(button(p, true)); + h.step(); + h.event(Event::PointerMoved(pos2(550.0, 100.0))); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "the view stayed put"); + h.event(button(pos2(550.0, 100.0), false)); + h.step(); +} + +/// The 200x100 test document at 400% (document x 25..175 shows in a 600-point canvas). +fn zoomed_view(height: u32) -> crate::state::View { + crate::state::View { + zoom: 4.0, + center: [100.0, height as f32 / 2.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, height], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } +} + +/// A press held just inside the canvas edge (past egui's click time, so egui starts a drag) that +/// slips a point past it is still a click: it draws no frame and doesn't scroll the view, which +/// would grow a frame under the still pointer. A new frame dragged past the edge does scroll. +#[test] +fn a_held_click_at_the_canvas_edge_draws_no_frame_and_does_not_scroll() { + use egui::{Event, PointerButton, pos2}; + let mut a = app(SampleType::U8); + a.ui.views = vec![zoomed_view(100)]; + a.ui.extras.rulers = false; + // The canvas starts 100 points from the window's left, so its left edge (no scrollbar there) + // has room past it. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let frame = h.state().ui.crop_rect; + assert!(h.state().crop.default_frame, "{frame:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert!(matches!(h.state().crop.drag, Some(CropDrag::Draw { .. })), "egui started a drag: {:?}", h.state().crop.drag); + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.ui.crop_rect, frame, "the click drew no frame"); + assert!(st.crop.default_frame); + // A new frame dragged from inside the canvas past its edge does scroll. + let start = pos2(300.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(240.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.crop.drag, Some(CropDrag::Draw { .. })), "{:?}", st.crop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the frame drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.crop_rect.is_some_and(|r| r[0] < 25.0), "the frame reached past what showed: {:?}", st.ui.crop_rect); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); +} + +/// A scrollbar drag is the scrollbar's, even with a crop drag still recorded (left over from a +/// drag a tool switch didn't end): dragged out past the canvas edge, it doesn't auto-scroll the +/// view as a crop drag would. +#[test] +fn a_scrollbar_drag_past_the_canvas_edge_does_not_scroll_for_a_crop_drag() { + use egui::{Event, PointerButton, pos2}; + let doc = Document::with_background("crop", Size::new(200, 200), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::Crop; + a.ui.tool_options.crop_shield.classic_mode = true; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x200 at 400% in a 600x400 canvas: both scrollbars show. + a.ui.views = vec![zoomed_view(200)]; + a.ui.crop_rect = Some([40.0, 10.0, 100.0, 40.0]); + a.crop.default_frame = false; + a.crop.drag = Some(CropDrag::Resize { hx: 1, hy: 0, start: [100.0, 25.0], rect: [40.0, 10.0, 100.0, 40.0], view: None }); + // The window is taller than the canvas, so the pointer can leave it below the bottom bar. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 500.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().ui.view.shows_scrollbars()); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // The horizontal bar's thumb (the view is centred, so is the thumb), dragged straight down + // out of the canvas: the bar keeps x, and nothing else may move the view. + let thumb = pos2(294.0, 400.0 - crate::canvas::scrollbars::THICKNESS / 2.0); + h.event(Event::PointerMoved(thumb)); + h.event(button(thumb, true)); + h.step(); + for p in [pos2(294.0, 420.0), pos2(294.0, 480.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, [100.0, 100.0], "the view stayed put"); + h.event(button(pos2(294.0, 480.0), false)); + h.step(); +} + /// #1918: a pending crop belongs to the document it was drawn on. File › New, opening a file /// or switching tabs cancels it; it never shows on, or crops, another document. /// A turned pending frame is dropped with its angle when another document becomes active, and @@ -984,6 +1260,126 @@ fn the_stylus_eraser_and_a_gesture_in_progress_keep_the_frame_pending() { assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); } +/// A tool key pressed while a drag on the frame is held (⇧C to Perspective Crop): the next +/// tool's events end the gesture, even a tool that takes every event before the Crop tool's +/// handler, and leaving then commits the frame as it does for any other tool (#3368). +#[test] +fn a_tool_switch_during_a_drag_ends_it_and_commits_the_frame() { + for tool in [Tool::PerspectiveCrop, Tool::Ruler, Tool::Slice, Tool::Frame, Tool::Artboard, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 20.0], [120.0, 80.0]], NONE); + tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 50.0, pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?}"); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 60), "{tool:?}"); + } +} + +/// The same mid-drag tool switch on the untouched default frame: a box drawn over it, or the +/// frame resized by a handle, is an edit and commits on leave; a box too small to keep goes back +/// to the default frame, which leaving drops (#9223). +#[test] +fn a_tool_switch_during_a_drag_on_the_default_frame_keeps_the_edit() { + let cases: [(&[[f64; 2]], Size); 3] = [ + (&[[20.0, 20.0], [120.0, 80.0]], Size::new(100, 60)), + (&[[200.0, 100.0], [150.0, 80.0]], Size::new(150, 80)), + (&[[20.0, 20.0], [20.0, 20.0]], Size::new(200, 100)), + ]; + for (path, size) in cases { + for tool in [Tool::PerspectiveCrop, Tool::Brush] { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame); + tool_event(&mut app, ToolEvent::Down { x: path[0][0], y: path[0][1], pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Move { x: path[1][0], y: path[1][1], pressure: 1.0 }, NONE); + assert!(app.crop.drag.is_some()); + app.ui.tool = tool; + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 50.0 }, NONE); + assert!(app.crop.drag.is_none(), "{tool:?} {path:?}"); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "{tool:?} {path:?}"); + assert_eq!(app.session.active().unwrap().doc.size, size, "{tool:?} {path:?}"); + } + } +} + +/// Real egui input: a tool picked while a box is being drawn over the untouched default frame +/// (a tool key mid-drag) commits that box on leaving, whichever tool it is; the canvas ends the +/// gesture before the frame sees the switch, also for the Hand, whose events never reach +/// `tool_event` (#9223). +#[test] +fn a_tool_switch_during_a_real_drag_commits_the_drawn_box() { + use egui::{Event, PointerButton, pos2}; + for tool in [Tool::Brush, Tool::PerspectiveCrop, Tool::Hand] { + let mut a = app(SampleType::U8); + a.sync_views(); + let v = &mut a.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.doc_size, v.rotation) = (2.0, [100.0, 50.0], false, [200, 100], 0.0); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + assert!(h.state().crop.default_frame, "{tool:?}"); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let p0 = pos2(200.0, 150.0); + h.event(Event::PointerMoved(p0)); + h.event(button(p0, true)); + h.step(); + for p in [pos2(220.0, 170.0), pos2(260.0, 190.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + assert!(h.state().crop.drag.is_some(), "{tool:?}"); + h.state_mut().ui.tool = tool; + h.event(Event::PointerMoved(pos2(270.0, 195.0))); + h.step(); + h.event(button(pos2(270.0, 195.0), false)); + h.run_steps(2); + let st = h.state(); + assert!(st.crop.drag.is_none(), "{tool:?}"); + assert_eq!(st.ui.crop_rect, None, "{tool:?}"); + assert_eq!(st.session.active().unwrap().doc.size, Size::new(30, 20), "{tool:?}: the drawn 60x40 screen px at 200%"); + } +} + +/// A press inside the untouched default frame that never moves draws nothing, so the frame +/// stays the default one and leaving drops it: no crop to the selection's bounds, released or +/// cut short by a tool switch (#9223). +#[test] +fn a_press_that_never_moves_leaves_the_default_frame_untouched() { + for switch in [false, true] { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 40.0, 60.0])); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, NONE); + if switch { + app.ui.tool = Tool::Brush; + end_drag_on_leave(&mut app); + } else { + tool_event(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }, NONE); + assert!(app.crop.default_frame, "a click is no edit"); + app.ui.tool = Tool::Brush; + } + ensure_frame(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size, st.history.past_len()), (Size::new(200, 100), steps), "switch: {switch}"); + assert_eq!(app.ui.crop_rect, None, "switch: {switch}"); + } +} + /// Image › Crop with a pending crop commits the frame (the one Image item Photoshop keeps). #[test] fn image_crop_menu_commits_a_pending_crop() { @@ -1288,6 +1684,94 @@ fn x_swaps_the_frame_orientation_not_the_colours() { assert!(!swap_orientation(&mut app)); } +/// #7738: with a ratio set, the options bar's ⇄ turns the frame with the ratio, as X does, +/// instead of fitting the swapped ratio inside the old frame; a second click brings it back. +#[test] +fn the_swap_button_turns_the_ratio_frame() { + use egui_kittest::kittest::Queryable; + let a = app(SampleType::U8); + let mut h = swap_button_harness(a, "16:9", [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); +} + +/// With W x H x Resolution the bar's ⇄ swaps W and H and turns the frame with them, as X +/// does, instead of fitting the turned size inside the old frame. +#[test] +fn the_swap_button_turns_a_size_preset_frame() { + use egui_kittest::kittest::Queryable; + let whr = |a: &mut PhotocraftApp| { + let o = &mut a.ui.tool_options; + (o.crop_width, o.crop_height, o.crop_resolution) = ("1600 px".into(), "900 px".into(), "72".into()); + }; + let mut a = app(SampleType::U8); + whr(&mut a); + let mut h = swap_button_harness(a, crate::crop_size::WHR, [60.0, 27.5, 140.0, 72.5]); + let near = |a: Option<[f64; 4]>, b: [f64; 4]| a.is_some_and(|a| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert_eq!((o.crop_ratio.as_str(), o.crop_width.as_str(), o.crop_height.as_str()), (crate::crop_size::WHR, "900 px", "1600 px")); + assert!(near(h.state().ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "45 x 80, not shrunk: {:?}", h.state().ui.crop_rect); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert!(near(h.state().ui.crop_rect, [60.0, 27.5, 140.0, 72.5]), "and back: {:?}", h.state().ui.crop_rect); + // X does the same. + let mut a = app(SampleType::U8); + whr(&mut a); + a.ui.tool_options.crop_ratio = crate::crop_size::WHR.into(); + a.ui.crop_rect = Some([60.0, 27.5, 140.0, 72.5]); + assert!(swap_orientation(&mut a)); + assert_eq!((a.ui.tool_options.crop_width.as_str(), a.ui.tool_options.crop_height.as_str()), ("900 px", "1600 px")); + assert!(near(a.ui.crop_rect, [77.5, 10.0, 122.5, 90.0]), "X: 45 x 80, not shrunk: {:?}", a.ui.crop_rect); +} + +/// A square default frame doesn't turn, so ⇄ leaves it untouched: not edited, and leaving the +/// tool drops it instead of cropping. +#[test] +fn the_swap_button_leaves_a_square_default_frame_untouched() { + use egui_kittest::kittest::Queryable; + let mut a = app(SampleType::U8); + a.crop.default_frame = true; + a.crop.editing = false; + let mut h = swap_button_harness(a, "1:1", [50.0, 0.0, 150.0, 100.0]); + h.get_by_label("Swaps height and width").click(); + h.run_steps(2); + assert_eq!(h.state().ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(h.state().crop.default_frame && !h.state().crop.editing, "still the untouched default frame"); + // X, the same swap from the keyboard. + let mut a = app(SampleType::U8); + a.crop.default_frame = true; + a.crop.editing = false; + a.crop.frame_for = a.session.active_index().zip(a.session.active()).map(|(i, st)| (i, st.doc.size.width, st.doc.size.height)); + a.ui.tool_options.crop_ratio = "1:1".into(); + a.ui.crop_rect = Some([50.0, 0.0, 150.0, 100.0]); + assert!(swap_orientation(&mut a)); + assert_eq!(a.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0])); + assert!(a.crop.default_frame && !a.crop.editing, "X keeps it untouched too"); +} + +fn swap_button_harness(mut a: PhotocraftApp, ratio: &str, rect: [f64; 4]) -> egui_kittest::Harness<'static, PhotocraftApp> { + a.ui.tool_options.crop_ratio = ratio.into(); + a.ui.crop_rect = Some(rect); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + a, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run_steps(4); + h +} + /// #1919: W × H beside the pointer while the frame is drawn or resized (its own size when /// turned), not while it moves or turns or between gestures. #[test] diff --git a/crates/ui-egui/src/tools/perspective_crop_ui.rs b/crates/ui-egui/src/tools/perspective_crop_ui.rs index 2a696bd5ed..ed60c90d41 100644 --- a/crates/ui-egui/src/tools/perspective_crop_ui.rs +++ b/crates/ui-egui/src/tools/perspective_crop_ui.rs @@ -53,6 +53,24 @@ pub fn pending(app: &PhotocraftApp) -> bool { app.ui.perspective_crop.is_some() || !app.pcrop.clicks.is_empty() || app.pcrop.drag.is_some() } +/// A box, corner or quad drag in progress: held past the canvas edge, it scrolls the view (#4183). +/// A press that hasn't moved further than a click doesn't: scrolling would turn a corner-placing +/// click near the edge into a box. +pub fn dragging(app: &PhotocraftApp) -> bool { + app.ui.tool == Tool::PerspectiveCrop + && match app.pcrop.drag { + Some(Drag::New { start, cur }) => !is_click(app, start, cur), + Some(_) => true, + None => false, + } +} + +/// A new-box press released within [`CLICK_PX`] screen points of where it began places a corner. +fn is_click(app: &PhotocraftApp, start: [f64; 2], cur: [f64; 2]) -> bool { + let click = crate::distort_ui::tolerance(app) * CLICK_PX / 8.0; + (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click +} + pub fn cancel(app: &mut PhotocraftApp) { app.ui.perspective_crop = None; app.pcrop.drag = None; @@ -121,8 +139,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { ToolEvent::Up { .. } => { drag_to(app, p); if let Some(Drag::New { start, cur }) = app.pcrop.drag { - let click = tol * CLICK_PX / 8.0; - if (cur[0] - start[0]).abs() <= click && (cur[1] - start[1]).abs() <= click { + if is_click(app, start, cur) { app.pcrop.clicks.push(start); if let [a, b, c, d] = app.pcrop.clicks[..] { app.ui.perspective_crop = Some(ordered([a, b, c, d])); @@ -327,6 +344,202 @@ mod tests { assert_eq!(app.ui.perspective_crop, None); } + /// Real egui input: a corner dragged past the canvas edge and held there scrolls the view, as a + /// marquee drag does (#4183), so the corner can reach the part of the image that was off screen. + #[test] + fn a_corner_held_past_the_canvas_edge_scrolls_the_view() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas: document x 25..175 shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + // The top-right corner: document (100, 10). + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::Corner { index: 1, .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] > before[0], "the view scrolled right: {:?} -> {:?}", before, st.ui.views[0].center); + assert!(st.ui.perspective_crop.is_some_and(|q| q[1][0] > 175.0), "the corner reached past what showed: {:?}", st.ui.perspective_crop); + h.event(button(out, false)); + h.step(); + } + + /// ⌘Space (Ctrl+Space off the Mac) pressed during a corner drag held past the canvas edge + /// selects the temporary Zoom, which takes the pointer from the drag: the view no longer + /// scrolls for the corner the Zoom froze. + #[test] + fn a_temporary_zoom_during_a_corner_drag_stops_the_scrolling() { + use egui::{Event, Key, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(800, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 800x100 at 400% in a 600x400 canvas: document x 25..175 shows, with room to scroll right. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [800, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + a.ui.perspective_crop = Some([[40.0, 10.0], [100.0, 10.0], [100.0, 40.0], [40.0, 40.0]]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center: [100.0, 50.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let grab = xf.to_screen(100.0, 10.0); + h.event(Event::PointerMoved(grab)); + h.event(button(grab, true, Modifiers::NONE)); + h.step(); + let out = pos2(650.0, grab.y); + for p in [pos2(grab.x + 40.0, grab.y), out] { + h.event(Event::PointerMoved(p)); + h.step(); + } + // Scrolling while the crop tool has the pointer. + let before = h.state().ui.views[0].center; + h.run_steps(2); + assert!(h.state().ui.views[0].center[0] > before[0], "the corner drag scrolls: {:?} -> {:?}", before, h.state().ui.views[0].center); + let zoom = Modifiers::COMMAND; + let zoom = if cfg!(target_os = "macos") { Modifiers { mac_cmd: true, ..zoom } } else { Modifiers { ctrl: true, ..zoom } }; + h.event(Event::ModifiersChanged(zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: zoom }); + h.step(); + assert_eq!(crate::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::hold_keys::Temporary::ZoomIn)); + assert!(h.state().pcrop.drag.is_some(), "the corner drag is still recorded"); + let before = h.state().ui.views[0].center; + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, before, "the temporary Zoom's press doesn't scroll for the corner"); + h.event(button(out, false, zoom)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: zoom }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + + /// A corner-placing click pressed just inside the canvas edge that slips a point past it doesn't + /// scroll the view (scrolling would turn it into a box); a new box dragged past the edge does. + #[test] + fn a_click_at_the_canvas_edge_places_a_corner_without_scrolling() { + use egui::{Event, Modifiers, PointerButton, pos2}; + let doc = Document::with_background("p", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut a = PhotocraftApp::new(s, crate::Services::default()); + a.ui.tool = Tool::PerspectiveCrop; + a.ui.extras.snap = false; + a.ui.extras.rulers = false; + // 200x100 at 400% in a 600x400 canvas 100 points from the window's left: document x 25..175 + // shows. + a.ui.views = vec![crate::state::View { + zoom: 4.0, + center: [100.0, 50.0], + fit_pending: false, + fill_pending: false, + doc_size: [200, 100], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let v = app.ui.views[0].clone(); + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 0.0), egui::vec2(600.0, 400.0)); + crate::canvas::canvas_view(app, ui, 0, rect, v, true); + }, + a, + ); + h.run_steps(2); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Pressed a point inside the left edge (the scrollbars sit right and bottom), slipping two + // points past it: still a click. + let press = pos2(101.0, 200.0); + let slip = pos2(99.0, 200.0); + h.event(Event::PointerMoved(press)); + h.event(button(press, true)); + h.step(); + // Held past egui's click time, the press becomes a drag that hasn't left the click size. + for p in [slip, pos2(98.5, 200.0), slip] { + h.event(Event::PointerMoved(p)); + h.run_steps(4); + } + assert_eq!(h.state().ui.views[0].center, [100.0, 50.0], "a click doesn't scroll the view"); + h.event(button(slip, false)); + h.step(); + let st = h.state(); + assert_eq!(st.pcrop.clicks.len(), 1, "the click placed a corner: {:?}", st.pcrop.clicks); + assert_eq!(st.ui.perspective_crop, None); + // A new box dragged from inside the canvas past its edge does scroll. + let start = pos2(200.0, 200.0); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + for p in [pos2(140.0, 220.0), pos2(50.0, 220.0)] { + h.event(Event::PointerMoved(p)); + h.step(); + } + let before = h.state().ui.views[0].center; + h.run_steps(10); + let st = h.state(); + assert!(matches!(st.pcrop.drag, Some(Drag::New { .. })), "{:?}", st.pcrop.drag); + assert!(st.ui.views[0].center[0] < before[0], "the box drag scrolled left: {:?} -> {:?}", before, st.ui.views[0].center); + h.event(button(pos2(50.0, 220.0), false)); + h.step(); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cb5354e550..cf5e648190 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -42,7 +42,9 @@ pub use color::{ }; #[cfg(test)] use color::{checker_tile_id, checker_uv}; -pub use dropdown::{dropdown, dropdown_hovered, dropdown_literal, dropdown_wheel_hovered, dropdown_with_tooltips, menu_scroll, swatch_popup}; +pub use dropdown::{ + dropdown, dropdown_hovered, dropdown_literal, dropdown_wheel_grouped, dropdown_wheel_hovered, dropdown_with_tooltips, menu_scroll, swatch_popup, +}; pub(crate) use fields::compact_wrapping_value_field; #[cfg(test)] use fields::{FieldOpts, NUMERIC_INPUT_LIMIT, value_field_in}; diff --git a/crates/ui-egui/src/widgets/dropdown.rs b/crates/ui-egui/src/widgets/dropdown.rs index 6f83b0edd2..ca0ea37d67 100644 --- a/crates/ui-egui/src/widgets/dropdown.rs +++ b/crates/ui-egui/src/widgets/dropdown.rs @@ -1,7 +1,7 @@ use super::panel::chevron_icon; use super::scrub::take_label_scrub_name; use super::slider::wheel_notches_of; -use super::{Pos2, Rect, Response, Ui, Vec2, type_ahead}; +use super::{Pos2, Rect, Response, Sense, Stroke, Tokens, Ui, Vec2, type_ahead, vec2}; /// A compact labelled dropdown in the studio style. pub fn dropdown(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { @@ -11,13 +11,13 @@ pub fn dropdown(ui: &mut Ui, id: &str, name: &str, current /// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel /// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). pub fn dropdown_literal(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { - let (chosen, _) = dropdown_with(ui, id, name, current, options, width, false, false); + let (chosen, _) = dropdown_with(ui, id, name, current, options, width, false, false, &|_| false); !chosen.is_empty() } /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, name: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, name, current, options, width, false, true); + let (chosen, hovered) = dropdown_with(ui, id, name, current, options, width, false, true, &|_| false); (!chosen.is_empty(), hovered) } @@ -35,7 +35,22 @@ pub fn dropdown_wheel_hovered( options: &[(T, &str)], width: f32, ) -> (Vec, Option) { - dropdown_with(ui, id, name, current, options, width, true, true) + dropdown_with(ui, id, name, current, options, width, true, true, &|_| false) +} + +/// [`dropdown_wheel_hovered`] whose open list draws a line above each option (but the first) that +/// `starts_group`: Photoshop's blend mode lists group their modes (#5532). The lines are only +/// drawn: the options, their order and how the wheel, the keys and the pointer pick them stay. +pub fn dropdown_wheel_grouped( + ui: &mut Ui, + id: &str, + name: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + starts_group: impl Fn(&T) -> bool, +) -> (Vec, Option) { + dropdown_with(ui, id, name, current, options, width, true, true, &starts_group) } #[allow(clippy::too_many_arguments)] @@ -48,6 +63,7 @@ fn dropdown_with( width: f32, wheel: bool, translate: bool, + starts_group: &dyn Fn(&T) -> bool, ) -> (Vec, Option) { let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); @@ -86,7 +102,10 @@ fn dropdown_with( // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for ((v, _), text) in options.iter().zip(texts.iter()) { + for (i, ((v, _), text)) in options.iter().zip(texts.iter()).enumerate() { + if i > 0 && starts_group(v) { + list_separator(ui); + } let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); @@ -135,6 +154,15 @@ fn dropdown_with( (chosen, hovered) } +/// A line between two groups of an open dropdown list's options, as wide as the list. Like egui's +/// separator it takes no width while egui sizes the list, so it never widens it. +fn list_separator(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let width = if ui.is_sizing_pass() { 0.0 } else { ui.available_width() }; + let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter().hline(rect.x_range(), rect.center().y, Stroke::new(1.0, t.separator)); +} + /// Makes an open dropdown list as wide as its widest option is when hovered, from the start. egui /// takes a plain row's frame stroke off its padding but draws a hovered row with the stroke, so a /// row grows by two stroke widths under the pointer, and the list never shrinks back: scrolling diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index d4c1bd81b3..9dd705f638 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -1442,3 +1442,44 @@ fn the_wheel_leaves_a_plain_dropdown_alone() { wheel(&mut h, egui::MouseWheelUnit::Line, egui::vec2(0.0, -1.0), Modifiers::NONE); assert_eq!(*h.state(), 0); } + +/// [`MODES`] in a wheel dropdown whose list draws a line above each option `starts_group` picks, +/// opened: the open list's rect and each option's row in it. +fn open_grouped_modes(starts_group: fn(&usize) -> bool) -> (egui::Rect, Vec) { + let mut h = Harness::builder().with_size(egui::vec2(300.0, 300.0)).build_ui_state( + move |ui, selected: &mut usize| { + super::dropdown_wheel_grouped(ui, "blend-mode", tl!("Blend Mode"), selected, &MODES, 120.0, starts_group); + }, + 0, + ); + crate::theme::apply(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.run_steps(4); + let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))); + let Some(list) = list else { panic!("the list is open") }; + let rows = MODES.iter().map(|(_, l)| h.get_all_by_label(l).map(|n| n.rect()).rfind(|r| list.contains_rect(*r)).unwrap_or(egui::Rect::NOTHING)).collect(); + (list, rows) +} + +/// The gaps between consecutive rows of an open list. +fn row_gaps(rows: &[egui::Rect]) -> Vec { + rows.windows(2).map(|w| w[1].top() - w[0].bottom()).collect() +} + +/// A grouped dropdown draws a line above each option starting a group, never above the first one, +/// and the lines take no width: the list is as wide as without them (#5532). +#[test] +fn a_grouped_dropdown_list_draws_a_line_above_each_group_and_keeps_its_width() { + let (plain_list, plain) = open_grouped_modes(|_| false); + let plain_gaps = row_gaps(&plain); + let (lo, hi) = plain_gaps.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), g| (lo.min(*g), hi.max(*g))); + assert!(hi - lo < 1.0, "no groups, even row gaps: {plain_gaps:?}"); + // Darken (index 2) starts a group; so does Normal, the first option, which never gets a line. + let (list, rows) = open_grouped_modes(|i| matches!(*i, 0 | 2)); + let gaps = row_gaps(&rows); + assert!((gaps[0] - lo).abs() < 0.5 && (gaps[2] - lo).abs() < 0.5, "only Dissolve/Darken is wider: {gaps:?}"); + assert!(gaps[1] > lo + 2.0, "a line between Dissolve and Darken: {gaps:?}"); + assert!((rows[0].top() - plain[0].top()).abs() < 0.5, "no line above the first row: {rows:?} vs {plain:?}"); + assert_eq!(list.width(), plain_list.width(), "the line doesn't widen the list"); +} diff --git a/crates/ui-egui/tests/it/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs index 4161631529..8f7a952b30 100644 --- a/crates/ui-egui/tests/it/crop_default_mode.rs +++ b/crates/ui-egui/tests/it/crop_default_mode.rs @@ -138,18 +138,17 @@ fn the_swap_button_and_x_swap_the_ratio_about_the_centre() { let before = mid(screen_box(&h)); h.get_by_label("Swaps height and width").click(); h.run_steps(3); + // Both turn the frame instead of fitting the turned ratio inside it (#7057, #7738). let button = h.state().ui.crop_rect.unwrap(); assert_eq!(h.state().ui.tool_options.crop_ratio, "2:3"); - assert!(((button[2] - button[0]) / (button[3] - button[1]) - 2.0 / 3.0).abs() < 1e-10); - assert!(button[0] >= original[0] && button[2] <= original[2] && button[1] >= original[1] && button[3] <= original[3]); + assert_eq!(button, [70.0, -20.0, 230.0, 220.0]); assert!(mid(screen_box(&h)).distance(before) < 0.01); h.state_mut().ui.crop_rect = Some(original); h.state_mut().ui.tool_options.crop_ratio = "3:2".into(); h.run_steps(3); h.key_press(Key::X); h.run_steps(3); - // X turns the frame instead of fitting the turned ratio inside it (#7057). - assert_eq!(h.state().ui.crop_rect, Some([70.0, -20.0, 230.0, 220.0])); + assert_eq!(h.state().ui.crop_rect, Some(button)); assert!(mid(screen_box(&h)).distance(before) < 0.01); } diff --git a/crates/ui-egui/tests/save_memory_gate.rs b/crates/ui-egui/tests/save_memory_gate.rs new file mode 100644 index 0000000000..fcaa2a056c --- /dev/null +++ b/crates/ui-egui/tests/save_memory_gate.rs @@ -0,0 +1,108 @@ +//! A save that won't fit in memory is refused before any encoding starts, with a message that +//! says how to free memory; the document stays open and unsaved (#10024). Its own test binary: +//! the memory simulation is process-wide. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Mutex; + +use photocraft_engine::Session; +use photocraft_raster::headroom; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +/// The simulation is process-wide: the tests take turns. +static LOCK: Mutex<()> = Mutex::new(()); + +fn app(writes: Rc>) -> PhotocraftApp { + let mut app = PhotocraftApp::new( + Session::new(), + Services { + export: Some(Box::new(|doc, path, _| { + photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) + })), + write: Some(Box::new(move |_, _| { + *writes.borrow_mut() += 1; + Ok(()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({"width": 8, "height": 8, "name": "Untitled-1"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn identity(app: &PhotocraftApp) -> (String, Option, u64, u64) { + let st = app.session.active().unwrap(); + (st.doc.name.clone(), st.path.clone(), st.revision, st.saved_revision) +} + +#[test] +fn a_save_that_does_not_fit_fails_and_leaves_the_document_unsaved() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let writes = Rc::new(RefCell::new(0)); + let mut app = app(writes.clone()); + let before = identity(&app); + headroom::simulate_available(Some(1 << 20)); + let refused = app.save_as(Some("big.psd".into())); + let refused_automation = app.save_automation(Some("auto.psd".into())); + headroom::simulate_available(None); + let err = refused.unwrap_err(); + assert!(err.contains("Not enough memory to save \u{201c}big.psd\u{201d}"), "{err}"); + assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}"); + let automation_err = refused_automation.unwrap_err(); + assert!(automation_err.contains("Not enough memory to save \u{201c}auto.psd\u{201d}"), "{automation_err}"); + assert_eq!(*writes.borrow(), 0, "nothing was encoded or written"); + assert_eq!(identity(&app), before, "the document is unsaved and unchanged"); + app.save_as(Some("big.psd".into())).unwrap(); + assert_eq!(*writes.borrow(), 1); +} + +fn warnings(app: &PhotocraftApp) -> usize { + app.ui.notices.iter().filter(|n| n.title == photocraft_ui_egui::memory_warning::TITLE).count() +} + +/// Going low posts one notice that stays; hovering low doesn't post again; recovering past the +/// higher threshold takes it down and arms the next warning. +#[test] +fn the_low_memory_warning_posts_once_and_rearms_with_hysteresis() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + use photocraft_ui_egui::memory_warning::{REARM_ABOVE, WARN_BELOW, tick}; + let mut app = app(Rc::new(RefCell::new(0))); + let mut now = 0.0; + let mut look = |app: &mut PhotocraftApp, available: Option| { + headroom::simulate_available(available); + now += 2.5; + tick(app, now); + }; + look(&mut app, None); + assert_eq!(warnings(&app), 0, "plenty of memory (and always so on the desktop)"); + look(&mut app, Some(WARN_BELOW - 1)); + assert_eq!(warnings(&app), 1); + assert!(app.ui.notices.iter().all(|n| n.sticky), "stays until dismissed"); + look(&mut app, Some(WARN_BELOW - 1)); + look(&mut app, Some(WARN_BELOW + 1)); + assert_eq!(warnings(&app), 1, "no second notice while it stays low"); + look(&mut app, Some(REARM_ABOVE)); + assert_eq!(warnings(&app), 0, "recovered: the stale notice is taken down"); + look(&mut app, Some(WARN_BELOW - 1)); + assert_eq!(warnings(&app), 1, "low again: warned again"); + // Checks are two seconds apart at most. + headroom::simulate_available(None); + app.ui.notices.clear(); + headroom::simulate_available(Some(0)); + tick(&mut app, now + 0.5); + assert_eq!(warnings(&app), 0); + headroom::simulate_available(None); +} + +#[test] +fn no_warning_without_a_document() { + let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + headroom::simulate_available(Some(0)); + photocraft_ui_egui::memory_warning::tick(&mut app, 100.0); + headroom::simulate_available(None); + assert_eq!(warnings(&app), 0); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d54c45e972..e118ddf51e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -251,7 +251,7 @@ 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; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. 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. +Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens, file places (`file.placeEmbedded` / `file.placeLinked`, File › Place and files dropped on the canvas: the file is read, decoded and resized on the worker, the layer is inserted when the job lands and Free Transform starts then) 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; a place shows its progress in the status bar only, so the view stays free to pan and zoom). `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; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. 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, the file being placed). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. ## Preferences diff --git a/docs/development.md b/docs/development.md index 0d73368277..18a7610ee0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -600,7 +600,9 @@ trunk serve --release # dev server on http://127.0.0.1:8765 Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Some static hosts refuse a file that large (Cloudflare's per-file cap is 25 MiB), so the release site doesn't hold it as one file: `packaging/web/package.sh` gzips it, cuts it into parts of at most 20 MiB under `app/`, and ships the page of `apps/photocraft-web-boot`, a small module that fetches the parts, has the browser inflate them (or inflates them itself where the browser can't) and passes the stream to the app's own loader (issue #5302). The script fails if any file in the site is over 24 MiB; the wasm's own size is no longer limited, but it is still what every visitor downloads, so watch it. Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and file-size checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `apps/photocraft-web-boot`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the wasm's size and the site's largest file in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). -URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. +URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. `?free-mb=N` is a diagnostic: the memory gates and the low-memory warning behave as if only N MiB could be allocated, so they can be checked in a browser without gigabytes of images (for example `?free-mb=300`, then Place a large image, or `?free-mb=400` for the warning). + +Running out of memory in the browser: a failed allocation aborts the WebAssembly module, and stable Rust can't turn that into an error, so the page froze on its last frame. Three layers now cover it (#10024). **Gate:** Place, Image Size, a smart-object re-render and every save or export (Save, Save a Copy, Export As, layer and batch exports) ask `photocraft_raster::headroom::ensure` first (a trial `try_reserve_exact` of the operation's estimated need plus 128 MiB, freed at once; always passes off the web), and refuse with an error naming the operation and the need when it won't fit (`photocraft_engine::memory_gate`, estimates per operation; `photocraft_io::estimate_export_bytes` for saves). **Warn:** `memory_warning` posts a sticky notice when linear memory is within 512 MiB of the 4 GiB wasm32 ceiling (`headroom::below` reads the memory size and allocates nothing, since a periodic trial reservation would grow memory that never shrinks), and re-arms at or above 768 MiB in simulations. Real wasm linear memory normally does not shrink, so the warning cannot normally re-arm. A PSD/PSB source's estimate adds a canvas-sized surface per layer (the layer count is read from its layer info, without decoding); one that isn't 8-bit RGB or grayscale, or whose sections can't be read, has no safe bound and is refused before Place or smart-object rendering on the web. **Backstop:** if the module still traps, `index.html` holds a hidden `#photocraft_stopped` message that `web.rs` reveals (the `error` and `unhandledrejection` events), saying whether it ran out of memory or hit an internal error (a panic's message is kept by a chained panic hook), with a Reload link; the "Leave site?" prompt is skipped then. To build and serve the web app entirely in Docker, run from the repository root: diff --git a/docs/gaps.md b/docs/gaps.md index 7184efed49..70e41e698d 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-11-05 · **Change:** minor (G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; 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; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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 @@ -342,6 +342,7 @@ what an agent can't finish alone. - **Estimate:** 150–250 h. ### G2. Interactive performance +- **Partial (2026-10-28, #9157, #9364, #6419):** Native startup prepares GPU compositor pipelines on a background pool; the CPU compositor draws while they build. The startup benchmark rows have no baseline yet. A Windows probe measures device stalls during the build; shader compiler fixes and DXC distribution remain separate work. - **Progress (2026-10-27):** Smooth and Feather also crop command mask reads and replacement to the nonzero selection bounds plus their blur reach and a zero margin. A 50×50 selection at radius 5 uses 62×62 and 82×82 samples respectively on a 36 MP @@ -391,10 +392,13 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation - (FILE-215-17); .atn, + Raw; spot plates beyond PSD and TIFF: PDF Separation (FILE-215-17); .atn, .shc, .csh presets (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-30, #10249):** visible spot channels of RGB and CMYK documents print over + the canvas composite as inks (the Multichannel overprint formula, in Channels panel order), open + visible, and print in the PSD thumbnail; flatten, export and the merged image stay free of them. + Open: ink order at overlaps and the look at solidity 0.5 and 1 against reference files. - **Progress (2026-10-23, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0, which stored spot ink the other way round, keep their ink. @@ -444,12 +448,25 @@ what an agent can't finish alone. launch, #5485), in-app bug report (DIST-220-3), Flathub (#173), starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with OpenCore (#1859). +- **Progress (2026-10-29):** the web build no longer freezes silently when it runs out of memory + (#10024). A failed allocation aborts the WebAssembly module and can't be caught, so Place, Image + Size, a smart-object re-render and Save first ask the allocator whether their estimated need + fits (`photocraft_raster::headroom`, `photocraft_engine::memory_gate`) and refuse with an error + that says what to do; a standing warning shows below 512 MiB; and a trap that still happens + shows a "PhotoCraft stopped" message with a Reload link. Still open: the decoded originals that + accumulate (#10030), Place decoding on the UI thread (#10031), Image Size re-rendering every + smart object at once (#10032), and recovering the document after a crash (#6615). Not every + allocation is gated, so a trap is still possible. + A layered PSD/PSB source is estimated as a canvas-sized surface per layer (the decoder keeps + one per layer; layers larger than the canvas are not counted), and one that isn't 8-bit RGB or + grayscale is refused on the web before Place or smart-object rendering. - **Estimate:** 30–60 h. **Human:** signing certificates. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed | | 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | | 2026-11-02 | minor | Action recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and places point text by canvas percentage; paragraph boxes remain absolute (AUTO-219-1, #4514); parity percentages unchanged | | 2026-11-01 | minor | Action recording stores portable layer targets and whole-text Type steps where unambiguous (AUTO-219-1, #4514); duplicate names remain ambiguous; parity percentages unchanged | @@ -460,8 +477,11 @@ what an agent can't finish alone. | 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 128 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-11-05 | minor | G8: Type > OpenType > Stylistic Sets submenu (Set 1–20) landed (TYPE-218-5, #10285); font-capability greying still missing; PhotoCraft-native PSDs already keep the sets since #10308 | | 2026-10-30 | minor | Art History Brush Opacity on the options bar, #10263; no estimate changed | +| 2026-10-28 | minor | G2: prepare the GPU compositor in the background; startup performance baseline pending (#9157) | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | +| 2026-10-30 | minor | G10: visible spot channels of RGB and CMYK documents overprint the canvas composite and the PSD thumbnail, and open visible (#10249); FILE-215-17 stays partial, narrowed to PDF Separation; estimate unchanged | +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index e8f6a6da1b..0fe3e98549 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (GPU compositor startup builds in the background, measurements pending; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR @@ -19,6 +19,7 @@ HDR display to verify. |---|---|---|---| | GPU canvas, zoom/pan, rotate view | Metal (macOS), DirectX/OpenCL (Windows) | wgpu 30: Metal, Vulkan, DX12, GL; backend selectable | done | | GPU compositing of layers, blend modes, effects | partial (Photoshop composites mostly on CPU) | wgpu compositor, ≤ 1/255 vs CPU oracle; Multichannel and > texture-limit regions fall back to CPU | done (ahead in places) | +| GPU compositor startup | available | Pipeline preparation runs in the background on native hardware; the CPU composites until ready. Windows WARP probe: 0.329 ms p95, 6.860 ms maximum device-work stall during two FXC builds; hardware measurements remain pending (#9157, #9364, #6419) | partial | | GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects. Camera Raw's preview runs its per-pixel stages (white balance to grain) in one wgpu pass, about 2-3 ms vs 11-28 ms on the CPU for the 858×572 proxy, depending on the machine; spatial stages, Calibration and OK still develop on the CPU (#6916) | partial | | Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial | | CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial | @@ -62,6 +63,7 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Build GPU compositor pipelines in the background; benchmark baseline pending (#9157) | | 2026-10-10 | major | First version | | 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) | | 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) | diff --git a/docs/scorecard.md b/docs/scorecard.md index c540f7b060..bf6ebcfb9b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 49 | 83 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 45 not run, 4 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 189 (target 0) | @@ -98,6 +98,8 @@ Baseline: 2026-10-05, 1 run, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Appl | P77 | Toggle an adjustment layer's visibility | ≤ 10 ms p95 | – | – | – | – | – | not run | [#6535](https://github.com/storytold/photocraft/issues/6535) | | P78 | Hide the top adjustment layer among 150 full-canvas layers, 6 MP, GPU budget forced to 512 MB (CPU fallback) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#6533](https://github.com/storytold/photocraft/issues/6533) | | P79 | Zoomed-out reduced composite of a 30000x30000 document with one painted layer (#9330 target: 60000x60000) | track only | – | – | – | – | – | not run | [#1015](https://github.com/storytold/photocraft/issues/1015) | +| B2a | First document: UI-thread time to first pixels | ≤ 100 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) | +| B2b | First document: GPU compositor ready | ≤ 2000 ms p95 | – | – | – | – | – | not run | [#9157](https://github.com/storytold/photocraft/issues/9157) | | P80 | App frame: full CPU refresh of a 24 MP document | ≤ 50 ms p95 | – | – | – | – | – | not run | [#9328](https://github.com/storytold/photocraft/issues/9328) | | P81 | Undo a brush dab on a full-canvas 16-bit 15000 x 10000 layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) | | P82 | Fill a 2 % selection on a full-canvas 24 MP layer + refresh | track only | – | – | – | – | – | not run | [#9329](https://github.com/storytold/photocraft/issues/9329) | @@ -143,7 +145,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90717b7417..f693728b8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -14,6 +14,7 @@ the measured checklists are [`scorecard.md`](scorecard.md) (`cargo xtask scoreca | Number | Value | Kind | |---|---|---| +| 2026-10-29 | minor | Stability row: web build memory handling (#10024), memory checks before big operations, a low-memory warning and a stopped page for a trap; no percentage or estimate changed | | **Feature breadth** (does each Photoshop feature exist?) | **~76%** | partly measured (menus, tools, panels, formats), weights below | | **Ready for real work** (could a professional replace Photoshop with it?) | **~45%** (range 40–50%) | estimated, weights and evidence below | | **Mainstream practitioner** (typical pro, weekly areas only) | **~43%** (38–48%) | estimated, [method](#mainstream-practitioner-43) | @@ -79,7 +80,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 11 of 183 preferences do nothing (scorecard, 2026-11-01; was 12 of 183); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | -| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | +| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; web build: Place, Image Size, smart-object re-render and Save are checked against available memory, a low-memory warning and a "stopped" page replace a silent freeze (2026-10-29, #10024); but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); Help › Check for Updates plus an opt-out check once per launch (DIST-220-1 partial, no periodic recheck) | [scorecard.md](scorecard.md#distribution) | @@ -174,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking; no per-camera colour for non-DNG cameras); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; recording uses implicit active layers, unique layer names, whole-text Type steps, bundled Move-tool nudges, relative keyboard document switches and canvas-relative point text (AUTO-219-1, #4514; duplicate names and paragraph boxes remain open); Actions panel remains partial (AUTO-219-2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -596,6 +597,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | 2026-11-01 | minor | Automation Batch: recursive folders, open documents, extended naming, stop/error log and folder-job progress/cancel; AUTO-219-5 done (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | UI / UX and Workspace rows: 11 of 183 preferences do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see. Percentages and estimates unchanged | | 2026-10-30 | minor | Art History Brush Opacity on the options bar, #10263; no estimate changed | +| 2026-10-30 | minor | Colour management: RGB and CMYK spot channels preview as inks over the composite and in the PSD thumbnail (#10249); FILE-215-17 narrows to PDF Separation; percentages unchanged | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/docs/ui-design.md b/docs/ui-design.md index c4bcd34882..54bbb30e26 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -57,6 +57,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Free Transform | `transform_tool.rs` | ⌘T. Corners and edges scale proportionally (⇧ frees them: an edge then stretches one axis); ⌥ scales about the reference point; ⌘-corner distorts; dragging outside rotates (⇧ snaps to 15°); dragging inside moves. Preview = document without the moving pixels + a textured 24×24 mesh. ↩ or a double-click commits via `edit.transform {rect, quad}`. With several layers selected one box frames and moves them all (`layers`; the Background is left out, Warp is one layer at a time). | | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | +| Paint Bucket cursor | `canvas.rs`, `tool_cursor.rs` | The standard bucket cursor has the same dark/light, open-centre crosshair as the Gradient tool, just below and to the right of its drop. The crosshair centre is the exact fill/click target; the bucket image sits above-left of that point. The shared cursor primitive rasterizes the existing styled bucket SVG and crosshair into one cached bitmap with that centre as its exact hotspot. Native desktop uses the entire image as the OS cursor, so its bucket and crosshair move together independently of the frame rate; there is no separate bucket overlay. The web and scaled Wayland paint the same unified image in one frame. A rasterization failure keeps its concise error message in the dialog and status bar; its diagnostic backtrace goes to the log once when the failure is cached. Precise Other Cursors selects the crosshair alone, Caps Lock toggles that choice, and Alt/Option samples with the Eyedropper. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | | Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80664d314b..7031163bfb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -263,11 +263,13 @@ Paragraph; the rest open as in-app floating windows. | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | +| Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…; the single-channel items act on the targeted row (a colour, alpha or spot channel, Quick Mask, the layer mask, a one-channel image's composite) and are greyed without one; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… (only the modes the open grayscale documents fill, a different document per channel; greyed below three documents) open their dialogs, here and in the rows' menu, and Split Channels runs at once | done (2026-11-06) | `channels_panel.rs` `panel_menu`, `channels_panel/tests.rs`, `panel_docking/tests.rs`, `dialogs/merge_channels_ui.rs`; #11451. Panel Options (thumbnail size) to come | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Channels panel menu (☰): the channel items, not only the rows' context menu; New Channel…, New Spot Channel…, Channel Options… and Merge Channels… open their dialogs (New Channel and Merge Channels are new), and Channel Options… in Quick Mask mode opens a targeted alpha channel's options (#11451). No percentage changed | | 2026-11-02 | minor | Edit › Transform › Distort: the side handles move their edge freely, both its corners by the drag, stopping before the box folds as a corner does; Perspective's side handles skew along their edge, as in Photoshop (#10085). Not a new command, so no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed | diff --git a/perf/budgets.toml b/perf/budgets.toml index 762a8d27b7..32d44b72d7 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -767,6 +767,28 @@ bench = "perf_scenarios" row = "zoomed-out reduced composite of the huge document with one painted layer" enforce = false # tracked only +# ---- #9157: background compositor startup ------------------------------------------------------ + +[[scenario]] +id = "B2a" +title = "First document: UI-thread time to first pixels" +issue = 9157 +bench = "perf_scenarios" +row = "first document: UI-thread time to first pixels" +metric = "p95" +budget_ms = 100 +enforce = false # nightly Metal baseline not recorded yet + +[[scenario]] +id = "B2b" +title = "First document: GPU compositor ready" +issue = 9157 +bench = "perf_scenarios" +row = "first document: GPU compositor ready" +metric = "p95" +budget_ms = 2000 +enforce = false # informational until a nightly Metal baseline exists + [[scenario]] id = "P80" title = "App frame: full CPU refresh of a 24 MP document" diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3a762050a7..e8195e6b51 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation" [[item]] id = "FILE-215-18"