mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
Merge remote-tracking branch 'origin/main' into ka-language
This commit is contained in:
commit
82b9447732
193 files changed
+11430
-664
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -26,5 +35,10 @@
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
<div id="photocraft_stopped" hidden role="alert">
|
||||
<p><strong>PhotoCraft stopped.</strong></p>
|
||||
<p>Changes since the last save in this tab can't be recovered. <a href="">Reload</a> to start again.</p>
|
||||
<p id="photocraft_stopped_detail"></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -26,5 +35,10 @@
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
<div id="photocraft_stopped" hidden role="alert">
|
||||
<p><strong>PhotoCraft stopped.</strong></p>
|
||||
<p>Changes since the last save in this tab can't be recovered. <a href="">Reload</a> to start again.</p>
|
||||
<p id="photocraft_stopped_detail"></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<String> = 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<String> {
|
||||
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<u64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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("<script"), "trunk injects the generated loader; the page has no script of its own");
|
||||
}
|
||||
|
||||
/// A module that traps (an allocation it could not satisfy, or a panic) leaves the last frame
|
||||
/// on screen. The page already holds the message, hidden, with a way to reload; Rust only
|
||||
/// reveals it and fills in the detail line, so it works even when Rust can no longer build text.
|
||||
#[test]
|
||||
fn the_page_holds_a_hidden_stopped_message_with_a_reload_link() {
|
||||
let html = include_str!("../index.html");
|
||||
let (_, element) = html.split_once("<div id=\"photocraft_stopped\"").expect("a stopped-message element");
|
||||
let (element, _) = element.split_once("</div>").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("<a href=\"\">Reload</a>"), "{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("<script"), "still no script of its own");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Ser
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
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<AtomicBool>) {
|
||||
use wasm_bindgen::closure::Closure;
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let cb = Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::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<AtomicBool>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<dyn FnMut(web_sys::ErrorEvent)>::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::<dyn FnMut(web_sys::PromiseRejectionEvent)>::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::<js_sys::Error>() {
|
||||
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<AtomicBool>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,17 +202,22 @@ pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
|
||||
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##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<filter id="f"><feOffset dx="3e9" dy="-3e9"/></filter><rect width="16" height="16" filter="url(#f)"/>
|
||||
</svg>"##;
|
||||
assert!(rasterize_mask(svg.as_bytes(), 16).is_none());
|
||||
}
|
||||
|
||||
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
|
||||
#[test]
|
||||
fn masks_load_no_images() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -341,6 +341,41 @@ pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
fn gpu_memo_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("gpu-compositor.json"))
|
||||
}
|
||||
|
||||
fn load_gpu_memo_text() -> Option<String> {
|
||||
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<PathBuf> {
|
||||
@@ -791,6 +826,14 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
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;
|
||||
|
||||
@@ -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<Item = u8>) -> 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<f32> {
|
||||
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<bool> {
|
||||
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<bool>)> {
|
||||
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<bool> = (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<bool> {
|
||||
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<String> = 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<Vec<bool>> = 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<Vec<bool>> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+18
-10
@@ -614,7 +614,7 @@ impl Solver<'_> {
|
||||
// `FUSE_BAND` of a seam are solved for.
|
||||
const FUSE_BAND: usize = 16;
|
||||
let seams: Vec<bool> = (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<usize>; 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<f32> = 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<f32> = (0..w * h).map(noise).collect();
|
||||
let hole: Vec<bool> = (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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,34 +54,9 @@ pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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
|
||||
|
||||
@@ -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<Rect>)
|
||||
|
||||
/// 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<Rect>, 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<Surface, photocraft_raster::Cancelled> {
|
||||
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<Rect>, budget: usize, ctl: &photocraft_raster::Interrupt) -> Surface {
|
||||
let mut cur: Option<Surface> = 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<Rect>, budget: usize) -> Surface {
|
||||
fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, 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<Rect>, 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<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<bool> = 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<Option<(Vec<bool>, 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<f32> = 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<u8> = (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<bool> {
|
||||
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<bool> = 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<f32> = 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<bool> {
|
||||
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<f32> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
|
||||
@@ -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<bool>, Vec<bool>) {
|
||||
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<bool> = (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;
|
||||
|
||||
@@ -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<ColorBvh>, right: Box<ColorBvh> },
|
||||
struct ColorBvh {
|
||||
points: Vec<[f32; 4]>,
|
||||
nodes: Vec<ColorNode>,
|
||||
}
|
||||
|
||||
#[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<Self, &'static str> {
|
||||
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<Vec<ColorNode>, &'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<ColorNode>) -> 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<Item = [f32; 4]>, tol: f32) -> Self {
|
||||
pub fn new(selected_colors: impl IntoIterator<Item = [f32; 4]>, tol: f32) -> Result<Self, &'static str> {
|
||||
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<Item = [f32; 4]>, tol: f32, budget: usize) -> Result<Self, &'static str> {
|
||||
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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
/// 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<Bounds> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
(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<bool> = (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<bool>)> {
|
||||
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<bool> {
|
||||
(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::<bool>::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]);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Rows>)>,
|
||||
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<Arc<Rows>> {
|
||||
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<Rows>) {
|
||||
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<RowCache>, 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<RowCache> {
|
||||
static C: OnceLock<Mutex<RowCache>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Item = f32>) {
|
||||
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<Arc<CmykSpace>>,
|
||||
/// sRGB → an RGB document's own profile, when that differs from sRGB.
|
||||
to_own: Option<Arc<Transform>>,
|
||||
}
|
||||
|
||||
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<usize> = (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<f32>)> =
|
||||
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<f32> {
|
||||
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<i32> {
|
||||
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<f32> = (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::<Vec<f32>>());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Surface, photocraft_raster::Cancelled> {
|
||||
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)
|
||||
}
|
||||
@@ -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<f32> =
|
||||
(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,18 @@ pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
|
||||
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<T>(out: &mut Vec<T>, 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<()> {
|
||||
|
||||
@@ -188,6 +188,8 @@ pub(crate) fn compound<R>(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<R>(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 {
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -397,16 +397,16 @@ impl ColorState {
|
||||
pub fn display_transform_for(&self, doc: &Document, display: Option<u32>) -> Result<Arc<Transform>> {
|
||||
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);
|
||||
|
||||
@@ -77,6 +77,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result<Value> {
|
||||
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 {
|
||||
|
||||
@@ -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<Surface> {
|
||||
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<Value> {
|
||||
@@ -408,6 +415,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
#[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::*;
|
||||
|
||||
@@ -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<u8>, linked: Option<String>, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<u8>),
|
||||
/// 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<String>,
|
||||
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<Arc<Document>>,
|
||||
/// 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<u8>, linked: Option<String>, p: &Value) -> Result<Self> {
|
||||
Self::with_file(s, name, PlaceFile::Bytes(bytes), linked, p)
|
||||
}
|
||||
|
||||
fn with_file(s: &Session, name: &str, file: PlaceFile, linked: Option<String>, p: &Value) -> Result<Self> {
|
||||
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<LayerId>,
|
||||
}
|
||||
|
||||
/// 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<Prepared> {
|
||||
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<u8>, linked: Option<S
|
||||
// A frame's image renders through its smart object below, which refuses a placement past the
|
||||
// memory budget; resampling it to the frame's size first would allocate that surface twice.
|
||||
let px = if frame.is_none() {
|
||||
let mut px = flattened_checked(&src, fmt, "file.place")?;
|
||||
ctx.progress(0.6, "Flattening");
|
||||
super::check_flattenable(&src, "file.place")?;
|
||||
let bounds = src.bounds();
|
||||
let mut px = ctx
|
||||
.stage(0.6, 0.7, "Flattening", |ctl| photocraft_compose::composite_to_surface_with_interrupt(&src, bounds, fmt, None, ctl))
|
||||
.map_err(|_| EngineError::Cancelled)?;
|
||||
ctx.check()?;
|
||||
if (scale - 1.0).abs() > 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<u8>, linked: Option<S
|
||||
corners.iter().map(|q| q.x).fold(f64::NEG_INFINITY, f64::max),
|
||||
corners.iter().map(|q| q.y).fold(f64::NEG_INFINITY, f64::max),
|
||||
];
|
||||
// Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, an
|
||||
// embedded place lands as a plain pixel layer. A linked place is always a smart object.
|
||||
let smart = frame.is_some()
|
||||
|| linked.is_some()
|
||||
|| p.get("smartObject").and_then(Value::as_bool).unwrap_or(s.prefs().general.always_create_smart_objects_when_placing);
|
||||
let source = match linked {
|
||||
Some(path) => 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<Value> {
|
||||
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<Value> {
|
||||
@@ -429,7 +565,6 @@ pub(super) fn place(s: &mut Session, p: &Value, linked: bool) -> Result<Value> {
|
||||
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<Value> {
|
||||
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)
|
||||
}
|
||||
@@ -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<u8> {
|
||||
let px: Vec<u8> = (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<f32> {
|
||||
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");
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String>) -> EngineError {
|
||||
@@ -726,8 +812,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
|
||||
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 })))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -566,7 +566,7 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,7 +70,11 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option<Rect>) -> 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<f32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pushes a pixel onto the grow frontier, reporting an allocation failure instead of aborting.
|
||||
fn try_push(stack: &mut Vec<usize>, 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<Value> {
|
||||
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<f32> = 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<usize> = 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<Value> {
|
||||
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<Value> {
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -357,6 +357,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result<S
|
||||
if let Some(img) = cache_get(&key) {
|
||||
return Ok(img);
|
||||
}
|
||||
crate::memory_gate::ensure(&format!("render the smart object \u{201c}{file_name}\u{201d}"), crate::memory_gate::smart_render_need(bytes))?;
|
||||
let doc = decode_source(file_name, bytes)?;
|
||||
let img = SourceImage { surface: Arc::new(flattened_source(&doc, fmt)?), bounds: doc.bounds() };
|
||||
cache_put(key, img.clone());
|
||||
|
||||
@@ -272,7 +272,7 @@ fn select_subject_samples_the_selected_layer_else_the_composite() {
|
||||
/// after Deselect); sampling something else or a pixel edit computes it again.
|
||||
#[test]
|
||||
fn select_subject_repeat_reuses_the_mask_until_pixels_change() {
|
||||
let (mut s, _) = disc(8);
|
||||
let (mut s, inside) = disc(8);
|
||||
let sel = |s: &Session| {
|
||||
let mut v = Vec::new();
|
||||
s.active().unwrap().doc.selection.as_ref().unwrap().read_region_into(Rect::new(0, 0, 200, 160), &mut v);
|
||||
@@ -298,7 +298,10 @@ fn select_subject_repeat_reuses_the_mask_until_pixels_change() {
|
||||
.unwrap();
|
||||
assert!(s.subject_cache.region.is_none());
|
||||
s.execute("select.subject", json!({})).unwrap();
|
||||
assert_eq!(sel(&s), first);
|
||||
// Recomputed on the edited pixels (one changed pixel may move the edge a little).
|
||||
assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s)));
|
||||
let v = sel_iou(&s, 200, 160, inside);
|
||||
assert!(v > 0.9, "IoU {v}");
|
||||
}
|
||||
|
||||
/// A vertical Gaussian-blurred edge at x = 40 (warm left, cool right) with the left half selected.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<u8>, linked: Option<String>, params: Value) -> Result<Started> {
|
||||
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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<u64> {
|
||||
if let Ok((w, h)) = photocraft_codecs::probe_size(bytes) {
|
||||
return Some(u64::from(w) * u64::from(h));
|
||||
}
|
||||
let be32 = |at: usize| -> Option<u64> { Some(u64::from(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))) };
|
||||
let be16 = |at: usize| -> Option<u64> { 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<u64> {
|
||||
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<usize> {
|
||||
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<u64> {
|
||||
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<i16>) -> Vec<u8> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<TypeLayerRef> {
|
||||
let mut busy = Vec::new();
|
||||
let mut docs: Vec<photocraft_doc::DocId> = 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<LayerId> { 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<photocraft_doc::Document>, ids: &[LayerId]) -> Option<photocraft_doc::Document> {
|
||||
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)
|
||||
}
|
||||
@@ -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!("{}{}", "<g>".repeat(20_000), "</g>".repeat(20_000));
|
||||
let hostile = format!("<!DOCTYPE svg [<!ENTITY a \"{deep}\">]><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\">&a;</svg>");
|
||||
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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<u64>) -> Room {
|
||||
let guard = Room(LOCK.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
headroom::simulate_available(bytes);
|
||||
guard
|
||||
}
|
||||
fn set(&self, bytes: Option<u64>) {
|
||||
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<u8> = (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<u8> { (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);
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = 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::<usize>().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::<usize>().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::<Vec<_>>()
|
||||
.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<f64>) -> String {
|
||||
n.map(|v| format!("{v:.3}")).unwrap_or_else(|| "null".into())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Vec<[f32; 4096]>>);
|
||||
|
||||
/// 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<u64> {
|
||||
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).
|
||||
|
||||
@@ -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<Adjustment> {
|
||||
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<Vec<f32>> = 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());
|
||||
}
|
||||
}
|
||||
+89
-5
@@ -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<Self, Unsupported> {
|
||||
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, Unsupported> {
|
||||
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<Self, BuildFailure> {
|
||||
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<wgpu::Error>) {
|
||||
fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat, sequential: bool) -> (Self, Option<wgpu::Error>) {
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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<ImageResource, IoError> {
|
||||
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()));
|
||||
|
||||
+242
-18
@@ -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 `<g>`) 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::fontdb::Database>) -> 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<Vec<u8>>>, usize);
|
||||
static CMAPS: OnceLock<Mutex<Cmaps>> = 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<usvg::fontdb::ID> {
|
||||
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<usvg::fontdb::Database>, 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<Parsed, IoError> {
|
||||
/// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one).
|
||||
fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<Parsed, IoError> {
|
||||
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<usvg::fontdb::Database>) -> 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<T>(f: impl FnOnce() -> T) -> Option<T> {
|
||||
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<std::borrow::Cow<'_, [
|
||||
/// The deepest element nesting in `xml`, stopping as soon as it passes `limit` (the result is
|
||||
/// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add
|
||||
/// nothing; quoted attribute values may hold `>` 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<usize, IoError> {
|
||||
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<usize, IoError> {
|
||||
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"<!--") {
|
||||
after(j.saturating_add(4), b"-->")
|
||||
} else if rest.starts_with(b"<?") {
|
||||
after(j.saturating_add(2), b"?>")
|
||||
} else if rest.starts_with(b"<!ENTITY") {
|
||||
let end = tag_end(j.saturating_add(2)).0;
|
||||
if xml.get(j.saturating_add(2)..end).is_some_and(|decl| decl.contains(&b'<')) {
|
||||
return Err(IoError::Svg("the SVG's DTD declares an entity holding markup, which isn't supported".into()));
|
||||
}
|
||||
end
|
||||
} else if rest.starts_with(b"<!") {
|
||||
// The parser ends ELEMENT, ATTLIST and NOTATION at the first `>`, 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"<?") {
|
||||
after(at.saturating_add(2), b"?>")
|
||||
} else if rest.starts_with(b"<!DOCTYPE") {
|
||||
doctype_end(at.saturating_add(9))?
|
||||
} else if rest.starts_with(b"<!") {
|
||||
tag_end(at.saturating_add(2)).0
|
||||
} else if rest.starts_with(b"</") {
|
||||
@@ -828,13 +955,13 @@ pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> 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<u8>), 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<tiny_skia::Pixmap, IoError> {
|
||||
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;
|
||||
|
||||
@@ -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!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{MAX_SIDE}\" height=\"{MAX_SIDE}\"><filter id=\"f\">\
|
||||
<feGaussianBlur stdDeviation=\"20\"/><feOffset dx=\"5\" dy=\"5\"/><feFlood flood-color=\"black\"/>\
|
||||
<feComposite operator=\"in\" in2=\"SourceAlpha\"/><feMerge><feMergeNode/><feMergeNode in=\"SourceGraphic\"/></feMerge>\
|
||||
<feMorphology radius=\"8\"/><feConvolveMatrix order=\"5\" kernelMatrix=\"{}\"/><feTurbulence baseFrequency=\"0.02\" numOctaves=\"10\"/>\
|
||||
</filter><rect width=\"{MAX_SIDE}\" height=\"{MAX_SIDE}\" filter=\"url(#f)\"/></svg>",
|
||||
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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\">\
|
||||
<filter id=\"f\"><feMorphology radius=\"1000\"/></filter>\
|
||||
<pattern id=\"p\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\" patternTransform=\"matrix(0 -0.01 100 100 0 0)\">\
|
||||
<rect width=\"20\" height=\"1.5\" filter=\"url(#f)\"/></pattern>\
|
||||
<rect width=\"100\" height=\"100\" fill=\"url(#p)\"/></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<usvg::fontdb::Database> {
|
||||
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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\" font-family=\"Inter\">I\u{2500}I</text></svg>";
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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<usvg::fontdb::Database> {
|
||||
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<Vec<u8>> = 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"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"><text x=\"5\" y=\"45\" font-family=\"Inter\">I\xE2\x94\x80I</text></svg>";
|
||||
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"))]
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result<Document<'i>
|
||||
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())
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!("<!DOCTYPE svg [{subset}]><svg {NS} width=\"8\" height=\"8\">{body}</svg>");
|
||||
let cases = [
|
||||
("entity", with_dtd(&format!("<!ENTITY a \"{}\">", deep("<g>", "</g>")), "&a;")),
|
||||
("single-quoted entity", with_dtd(&format!("<!ENTITY a '{}'>", deep("<g>", "</g>")), "&a;")),
|
||||
("quote in a comment", with_dtd("<!-- ' -->", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
|
||||
("quote in an element declaration", with_dtd("<!ELEMENT x '>", &format!("{}<desc>'</desc>", deep("<g>", "</g>")))),
|
||||
(
|
||||
"entity between quoted attribute lists",
|
||||
with_dtd(&format!("<!ATTLIST svg x CDATA '><!ENTITY a \"{}\"><!ATTLIST svg y CDATA '>", deep("<g>", "</g>")), "&a;"),
|
||||
),
|
||||
("entity between quoted notations", with_dtd(&format!("<!NOTATION n SYSTEM '><!ENTITY a \"{}\"><!NOTATION m SYSTEM '>", deep("<g>", "</g>")), "&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(
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\
|
||||
<!ENTITY ns_svg \"http://www.w3.org/2000/svg\"> <!-- a ' and a <g> --> <?pi > ?> <!ATTLIST svg data-x CDATA '<'> <!ENTITY red '#ff0000'> <!ENTITY c \"© <g>\"> ]>\
|
||||
<svg xmlns=\"&ns_svg;\" width=\"8\" height=\"8\"><title>&c;</title><rect width=\"8\" height=\"8\" fill=\"&red;\"/></svg>",
|
||||
);
|
||||
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!(
|
||||
"<svg {NS} width=\"{side}\" height=\"{side}\"><filter id=\"f\">{fe}</filter><rect width=\"{side}\" height=\"{side}\" fill=\"red\" filter=\"url(#f)\"/></svg>"
|
||||
)
|
||||
};
|
||||
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 = "<filter id=\"f\"><feMorphology radius=\"10\"/></filter>";
|
||||
let inner = format!("<svg {NS} width=\"20\" height=\"20\">{f}<rect width=\"20\" height=\"20\" filter=\"url(#f)\"/></svg>");
|
||||
let embedded = base64_data_url("image/svg+xml", inner.as_bytes());
|
||||
for svg in [
|
||||
drawing(2000, "<feMorphology operator=\"dilate\" radius=\"1000\"/>"),
|
||||
drawing(2000, &format!("<feConvolveMatrix order=\"100\" kernelMatrix=\"{kernel}\"/>")),
|
||||
drawing(2000, "<feTurbulence baseFrequency=\"0.01\" numOctaves=\"1000000\"/>"),
|
||||
format!(
|
||||
"<svg {NS} width=\"2000\" height=\"2000\" viewBox=\"0 0 20 20\">{f}<mask id=\"m\"><rect width=\"20\" height=\"20\" fill=\"white\" filter=\"url(#f)\"/></mask><rect width=\"20\" height=\"20\" mask=\"url(#m)\"/></svg>"
|
||||
),
|
||||
format!(
|
||||
"<svg {NS} width=\"2000\" height=\"2000\">{f}<pattern id=\"p\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\" patternTransform=\"scale(100)\"><rect width=\"20\" height=\"20\" filter=\"url(#f)\"/></pattern><rect width=\"2000\" height=\"2000\" fill=\"url(#p)\"/></svg>"
|
||||
),
|
||||
format!("<svg {NS} width=\"2000\" height=\"2000\"><image width=\"2000\" height=\"2000\" href=\"{embedded}\"/></svg>"),
|
||||
] {
|
||||
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, "<feGaussianBlur stdDeviation=\"8\"/><feMorphology radius=\"3\"/><feTurbulence baseFrequency=\"0.05\" numOctaves=\"4\"/>");
|
||||
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, "<feMorphology radius=\"20\"/>");
|
||||
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!("<pattern id=\"p{i}\" width=\"4\" height=\"4\" patternUnits=\"userSpaceOnUse\"><rect width=\"4\" height=\"4\" fill=\"{paint}\" stroke=\"{paint}\"/></pattern>")
|
||||
})
|
||||
.collect();
|
||||
format!("<svg {NS} width=\"8\" height=\"8\">{defs}<rect width=\"8\" height=\"8\" fill=\"url(#p0)\"/></svg>")
|
||||
};
|
||||
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!("<rect width=\"500\" height=\"500\" fill=\"url(#p{})\"/>", i + 1)
|
||||
} else {
|
||||
"<rect width=\"500\" height=\"500\" fill=\"red\" filter=\"url(#f)\"/>".into()
|
||||
};
|
||||
format!("<pattern id=\"p{i}\" width=\"500\" height=\"500\" patternUnits=\"userSpaceOnUse\">{body}</pattern>")
|
||||
})
|
||||
.collect();
|
||||
format!("<svg {NS} width=\"500\" height=\"500\"><filter id=\"f\">{fe}</filter>{defs}<rect width=\"500\" height=\"500\" fill=\"url(#p0)\"/></svg>")
|
||||
};
|
||||
// Just over the budget (about 3.5 billion reads), so a regression fails in seconds, not hours.
|
||||
let fe = "<feMorphology operator=\"dilate\" radius=\"50\"/>";
|
||||
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, "<feGaussianBlur stdDeviation=\"2\"/>")).document.layers.is_empty());
|
||||
let groups =
|
||||
format!("<svg {NS} width=\"10\" height=\"10\">{}<rect width=\"10\" height=\"10\"/>{}</svg>", "<g opacity=\"0.99\">".repeat(250), "</g>".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!("<svg {NS} width=\"100\" height=\"100\"><image width=\"1e-30\" height=\"50\" href=\"{nested}\"/></svg>");
|
||||
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 = "<filter id=\"f\"><feOffset dx=\"3e9\" dy=\"-3e9\"/></filter>\
|
||||
<rect width=\"50\" height=\"50\" filter=\"url(#f)\"/><rect id=\"plain\" x=\"60\" y=\"60\" width=\"20\" height=\"20\"/>";
|
||||
let svg = format!("<svg {NS} width=\"100\" height=\"100\">{offset}</svg>");
|
||||
let r = open(&svg);
|
||||
assert_eq!(r.document.layers.len(), 1, "{:?}", r.document.layers.iter().map(|l| &l.name).collect::<Vec<_>>());
|
||||
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!("<svg {NS} width=\"100\" height=\"100\">{offset}{}</svg>", "<rect width=\"1\" height=\"1\"/>".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!(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<f32> {
|
||||
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).
|
||||
|
||||
+182
-79
@@ -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;
|
||||
|
||||
|
||||
@@ -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<u32> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<u64>) {
|
||||
SIMULATED.store(bytes.unwrap_or(OFF), Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn simulated() -> Option<u64> {
|
||||
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<u8> = 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<u64> {
|
||||
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<u64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<u64>) {
|
||||
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<f64> = 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<String> = 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<u8>)> =
|
||||
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
|
||||
|
||||
@@ -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<chrono::Utc> = 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Rect> {
|
||||
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<Rect> = 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<f32> = 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");
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn choices() -> Vec<((Option<BlendMode>, bool), &'static str)> {
|
||||
fn blend_items(behind: bool, clear: bool) -> Vec<Item> {
|
||||
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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -111,6 +111,9 @@ pub(crate) struct Redraw {
|
||||
stop: Arc<AtomicBool>,
|
||||
started_ms: f64,
|
||||
fallback: Option<crate::render_path::CpuReason>,
|
||||
/// 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<Document>,
|
||||
display: u64,
|
||||
@@ -286,6 +289,7 @@ pub(crate) fn start(
|
||||
visible: Rect,
|
||||
region: Rect,
|
||||
fallback: Option<crate::render_path::CpuReason>,
|
||||
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<Document>, display: Option<Arc<CanvasDisplay>>, 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<crate::render_path::CpuReason>,
|
||||
gpu_attempt_ms: f64,
|
||||
/// Blocks collected for [`Kept`], their bytes, and the cap.
|
||||
keep: (Vec<Option<Vec<u8>>>, 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Vec<[f32; 4]>>,
|
||||
/// 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<egui::TextureHandle>,
|
||||
source: Option<SampledComposite>,
|
||||
}
|
||||
|
||||
@@ -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<std::sync::Arc<photocraft_cms::Transform>>,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// How the canvas shows `doc` on `display` (`None`: the main window's).
|
||||
pub fn of(app: &PhotocraftApp, doc: &Document, display: Option<u32>) -> 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<u32>) -> 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<Vec<[f32; 4]>>) -> 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]>>) -> 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<photocraft_doc::LayerId>) -> Option<Vec<Color32>> {
|
||||
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<usize> = (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<f32>)> = if vis > 0 {
|
||||
let spot_ids: Vec<usize> = 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<usize> = 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<usize> = (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<f32>, 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<egui::TextureId> {
|
||||
/// 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<u32>) -> Option<egui::TextureId> {
|
||||
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)]
|
||||
|
||||
@@ -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<u8> = (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");
|
||||
}
|
||||
}
|
||||
@@ -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::<String>(id)) {
|
||||
ctx.data_mut(|data| data.remove::<String>(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::<String>(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
|
||||
|
||||
@@ -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<std::sync::atomic::AtomicBool>,
|
||||
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<Result<(), (String, bool)>> {
|
||||
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<String> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<photocraft_gpu::Compositor>),
|
||||
Failed(photocraft_gpu::Unsupported),
|
||||
Adopted,
|
||||
}
|
||||
|
||||
pub(super) type Slot = Arc<Mutex<BuildState>>;
|
||||
pub(super) type Completion = Arc<Mutex<Option<Result<(), (String, bool)>>>>;
|
||||
|
||||
#[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<T: Send>() {}
|
||||
assert_send::<photocraft_gpu::Compositor>();
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user