Web build: big operations that won't fit fail with an error instead of trapping, and a trap says so (#10129)

* Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output

New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it
at once (web only; native always passes), below(bytes) tells whether less than that can be
allocated, and simulate_available fakes the answer for tests and diagnostics.

PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged
blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now
reserves the exact encoded length.

* Big operations that won't fit in memory fail with an error instead of trapping

Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated
need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation,
photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the
operation needed and what to do (save, Edit > Purge > All, close documents).

A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can
save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay.

* Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory

index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals
it when the module traps (the window error event, or an unhandled rejection from a spawned
future), tells running out of memory from an internal error by the panic message a chained panic
hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the
low-memory warning see only N MiB, for checking them in a browser.

* Docs: web build memory handling in the gaps list and the parity assessment

* Engine: use is_multiple_of in the megabyte formatter

* Cargo.lock: the web shell depends on photocraft-raster

* Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires

* Save check counts one copy of the document, and runs once per save

The encoded file's buffer is reserved fallibly, so a save it doesn't fit
in fails with an error instead of trapping; counting it as well refused
saves that would have fitted. On the background-save path the check ran
in write_document and again in start_save, which other callers reach
directly; it now runs only in start_save there.

* Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked

The low-memory warning's periodic check reads the size of linear memory
instead of reserving a block: wasm memory never shrinks, so a trial every
few seconds raised the high-water mark by up to 768 MiB for nothing. The
operation checks keep their trial reservation.

PSD and PSB saves always build a canvas-sized merged image (a placeholder
without maximum compatibility) and hold it raw and encoded; the save
estimate now counts it. The synchronous automation save checks memory
before encoding, as File > Save does, and its test asserts the memory
error.

The trap listeners go in before the app is built, so a trap while
building it shows the stopped message too, and a memory access out of
bounds reads as unexpected rather than as running out of memory.

* Cover layered PSD preflight limitation

* Fix layered PSD test iteration

* Docs: layered PSD preflight limitation in the gaps list

* Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024)

* Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD

The PSD check admitted a file only when its color-mode data, image
resources and layer section were all empty. Every Photoshop file has
image resources (at least the resolution), so on the web nearly every
real PSD was refused for Place, PSD smart objects could not render,
and Image Size was refused for documents holding one, whatever the
free memory (u64::MAX never fits).

The color-mode data and image resources hold no pixels, so they are
now only skipped. A layered file reads its layer count from the layer
info (signed, absolute value) and adds a canvas-sized surface and mask
per layer to the flat estimate. Files that are not 8-bit RGB or
grayscale, keep their layers in a Layr tagged block, or whose sections
can't be read stay unbounded and refused, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web stopped page: match trap messages ignoring case

Safari reports the allocation-failure abort as "Unreachable code
should not be executed" and Firefox says "out of memory", which the
case-sensitive matching missed: the trap was not recognised, or was
shown as an unexpected stop instead of running out of memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Export estimate: count the merged image OpenRaster writes

The ORA writer keeps the layers but also flattens the document for
mergedimage.png, a 16 B/px composite of the canvas, which the
estimate left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 22:04:59 +01:00
1 parent a3c0b7258c
commit 6326117c53
31 files changed
+1503 -9

No files matched your search

Generated
+1
View File
@@ -5629,6 +5629,7 @@ dependencies = [
"photocraft-io",
"photocraft-paint",
"photocraft-psd",
"photocraft-raster",
"photocraft-ui-egui",
"rfd",
"serde_json",
+14
View File
@@ -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>
+2 -1
View File
@@ -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 }
+14
View File
@@ -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>
+148
View File
@@ -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);
}
}
+24 -1
View File
@@ -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");
}
}
+79 -2
View File
@@ -25,6 +25,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 +59,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 +81,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,
@@ -180,11 +192,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 +212,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()
}
+2
View File
@@ -330,6 +330,8 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<S
});
let fmt = d.doc.pixel_format();
let vector = photocraft_io::svg::is_svg(&bytes);
// 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 approve = match p.get("approveHighMemory") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
+2
View File
@@ -263,6 +263,8 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
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] });
@@ -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());
+1
View File
@@ -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;
+346
View File
@@ -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]);
}
}
+261
View File
@@ -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);
}
+118
View File
@@ -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);
}
}
+2
View File
@@ -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};
+18
View File
@@ -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);
}
}
+167
View File
@@ -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());
}
}
+1
View File
@@ -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;
+2
View File
@@ -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,
+12
View File
@@ -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
View File
@@ -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")))]
@@ -216,6 +216,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result<Value
let paths = batch_paths(&path, &docs);
let mut exported = Vec::new();
for (doc, path) in docs.iter().zip(paths) {
crate::app::ensure_memory_to_save(doc, &path)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(doc, &path, &ExportSettings { xmp_all: false, ..Default::default() })?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
@@ -1062,6 +1063,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
/// Exports `doc` to `path` with the dialog settings `f` (Export As, and its replayed action step).
fn write_export(app: &mut PhotocraftApp, doc: &Document, f: &Map<String, Value>, path: &str, source_profile: &Profile) -> Result<Value, String> {
let out = export_document(doc, f, None, source_profile)?;
crate::app::ensure_memory_to_save(&out, path)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(&out, path, &settings(f))?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
@@ -1111,6 +1113,7 @@ fn confirm_layers(app: &mut PhotocraftApp, f: &Map<String, Value>, ids: &[photoc
let mut exported = Vec::new();
for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) {
let out = export_document(doc, &f, None, &source_profile)?;
crate::app::ensure_memory_to_save(&out, &path)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(&out, &path, &settings)?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
+1
View File
@@ -286,6 +286,7 @@ pub fn start_save(
// The engine's wording for a command refused while a job runs.
return Err(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", j.label));
}
crate::app::ensure_memory_to_save(&st.doc, &path)?;
let name = crate::file_open::display_name(&path);
// One writer per file: an older snapshot must not land after a newer one.
if app.jobs.saves.iter().any(|s| s.path == path) {
+3
View File
@@ -41,6 +41,7 @@ pub mod icons;
pub mod jobs_ui;
pub(crate) mod last_brush;
pub mod links;
pub mod memory_warning;
pub mod menu_catalog;
pub mod menus;
pub mod monitor_status;
@@ -456,6 +457,8 @@ pub struct PhotocraftApp {
/// the frame the pointer leaves, so a long stationary hover never counts as elapsed time
/// (#2022); set by `notices::show`.
pub(crate) notices_hovered: bool,
/// The low-memory warning's schedule and state (`memory_warning`).
pub(crate) low_memory: memory_warning::Watch,
/// Apply theme on first frame.
styled: bool,
/// Whether the window uses an integrated (transparent) macOS title bar.
+137
View File
@@ -0,0 +1,137 @@
//! A standing warning while the web build's memory is nearly full (#10024).
//!
//! On the web a failed allocation aborts the module and the page freezes without a message. The
//! big operations refuse up front when they won't fit (`photocraft_engine::memory_gate`); this
//! tells the user earlier, while there is still room to save: when the module's memory is within
//! [`WARN_BELOW`] of the wasm32 ceiling, a notice that stays until dismissed asks them to save and
//! free memory. The answer comes from [`photocraft_raster::headroom::below`], which reads the
//! size of linear memory and allocates nothing. Linear memory never shrinks, so on the web the
//! warning doesn't re-arm once shown (only `?free-mb` or a test can make memory recover). It is always "plenty" on
//! the desktop, so the warning never shows there.
use photocraft_raster::headroom;
use crate::PhotocraftApp;
/// Warn when less than this can be allocated.
pub const WARN_BELOW: u64 = 512 << 20;
/// Warn again only after this much (or more) was available again, so a figure hovering around
/// the threshold doesn't flicker the notice on and off.
pub const REARM_ABOVE: u64 = 768 << 20;
/// Seconds between checks.
const CHECK_EVERY: f64 = 2.0;
/// The notice's title (an English source string, translated when drawn).
pub const TITLE: &str = "Memory is nearly full";
/// What a check decided.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
/// Nothing to do.
Idle,
/// Memory just ran low: post the notice.
Warn,
/// Memory recovered: the warning is armed again, and the old notice is stale.
Rearm,
}
/// The warning's memory: when it last looked, and whether it has warned and not yet re-armed.
#[derive(Clone, Debug, Default)]
pub struct Watch {
next_check: f64,
warned: bool,
}
impl Watch {
/// `true` when it is time to look again at `now` (seconds); then schedules the next look.
pub fn due(&mut self, now: f64) -> bool {
if now < self.next_check {
return false;
}
self.next_check = now + CHECK_EVERY;
true
}
/// `true` between a warning and its re-arming.
pub fn warned(&self) -> bool {
self.warned
}
/// Advances with what a check found: `below_warn` is "less than [`WARN_BELOW`] is available",
/// `below_rearm` is "less than [`REARM_ABOVE`] is available".
pub fn step(&mut self, below_warn: bool, below_rearm: bool) -> Step {
if !self.warned {
if below_warn {
self.warned = true;
return Step::Warn;
}
return Step::Idle;
}
if below_rearm {
return Step::Idle;
}
self.warned = false;
Step::Rearm
}
}
/// Run once per frame: at most every two seconds, and only while a document is open, posts the
/// warning when memory is nearly full, and takes it down again once memory has recovered.
pub fn tick(app: &mut PhotocraftApp, now: f64) {
if app.session.active().is_none() || !app.low_memory.due(now) {
return;
}
let below_warn = headroom::below(WARN_BELOW);
let below_rearm = if app.low_memory.warned() { headroom::below(REARM_ABOVE) } else { below_warn };
match app.low_memory.step(below_warn, below_rearm) {
Step::Idle => {}
Step::Warn => post(app),
Step::Rearm => app.ui.notices.retain(|n| n.title != TITLE),
}
}
fn post(app: &mut PhotocraftApp) {
if app.ui.notices.iter().any(|n| n.title == TITLE) {
return;
}
let lines = vec![
"Save your work now: if the browser runs out of memory this page stops, and changes since the last save can't be recovered.".to_owned(),
"To make room, close documents you don't need or use Edit › Purge › All.".to_owned(),
];
let id = crate::notices::post(app, TITLE, lines, true, None);
if let Some(notice) = app.ui.notices.iter_mut().find(|n| n.id == id) {
notice.sticky = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warns_once_then_waits_for_the_higher_threshold() {
let mut w = Watch::default();
assert_eq!(w.step(false, false), Step::Idle);
assert_eq!(w.step(true, true), Step::Warn);
// Staying low, or hovering between the two thresholds, doesn't warn again.
assert_eq!(w.step(true, true), Step::Idle);
assert_eq!(w.step(false, true), Step::Idle);
assert_eq!(w.step(true, true), Step::Idle);
// Rising above the higher threshold re-arms; dropping again warns again.
assert_eq!(w.step(false, false), Step::Rearm);
assert_eq!(w.step(false, false), Step::Idle);
assert_eq!(w.step(true, true), Step::Warn);
}
#[test]
fn checks_are_spaced_two_seconds_apart() {
let mut w = Watch::default();
assert!(w.due(0.0));
assert!(!w.due(1.0));
assert!(!w.due(1.99));
assert!(w.due(2.0));
assert!(!w.due(3.0));
}
}
@@ -656,6 +656,7 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
/// are any.
pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result<Value, String> {
let settings = choices.apply(app.save_settings());
crate::app::ensure_memory_to_save(doc, path)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(doc, path, &settings)?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
+16 -3
View File
@@ -35,6 +35,10 @@ pub struct Notice {
/// and is reset while the pointer rests on the stack (issue #2022). `None` until first shown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shown_at: Option<f64>,
/// Stays until dismissed (or until whatever it warns of has passed), whatever the auto-hide
/// setting: for a warning the user must not miss, such as nearly full memory.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sticky: bool,
}
impl Notice {
@@ -48,14 +52,23 @@ impl Notice {
/// Show a notice (newest last); returns its id.
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool, dismiss_pref: Option<&str>) -> u64 {
let id = app.ui.alloc_id();
app.ui.notices.push(Notice { id, title: title.into(), lines, error, dismiss_pref: dismiss_pref.map(str::to_owned), args: Vec::new(), shown_at: None });
app.ui.notices.push(Notice {
id,
title: title.into(),
lines,
error,
dismiss_pref: dismiss_pref.map(str::to_owned),
args: Vec::new(),
shown_at: None,
sticky: false,
});
cap_notices(app);
id
}
fn cap_notices(app: &mut PhotocraftApp) {
while app.ui.notices.len() > MAX_NOTICES {
let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none()).unwrap_or(0);
let oldest_temporary = app.ui.notices.iter().position(|notice| notice.dismiss_pref.is_none() && !notice.sticky).unwrap_or(0);
app.ui.notices.remove(oldest_temporary);
}
}
@@ -186,7 +199,7 @@ fn expire(app: &mut PhotocraftApp, now: f64, hovered: bool, just_left: bool, aut
}
let mut expired = Vec::new();
let mut next = f64::INFINITY;
for n in &mut app.ui.notices {
for n in app.ui.notices.iter_mut().filter(|n| !n.sticky) {
if n.shown_at.is_none() {
n.shown_at = Some(now);
}
+108
View File
@@ -0,0 +1,108 @@
//! A save that won't fit in memory is refused before any encoding starts, with a message that
//! says how to free memory; the document stays open and unsaved (#10024). Its own test binary:
//! the memory simulation is process-wide.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Mutex;
use photocraft_engine::Session;
use photocraft_raster::headroom;
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::json;
/// The simulation is process-wide: the tests take turns.
static LOCK: Mutex<()> = Mutex::new(());
fn app(writes: Rc<RefCell<u32>>) -> PhotocraftApp {
let mut app = PhotocraftApp::new(
Session::new(),
Services {
export: Some(Box::new(|doc, path, _| {
photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
write: Some(Box::new(move |_, _| {
*writes.borrow_mut() += 1;
Ok(())
})),
..Default::default()
},
);
app.run("file.new", json!({"width": 8, "height": 8, "name": "Untitled-1"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app
}
fn identity(app: &PhotocraftApp) -> (String, Option<String>, u64, u64) {
let st = app.session.active().unwrap();
(st.doc.name.clone(), st.path.clone(), st.revision, st.saved_revision)
}
#[test]
fn a_save_that_does_not_fit_fails_and_leaves_the_document_unsaved() {
let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let writes = Rc::new(RefCell::new(0));
let mut app = app(writes.clone());
let before = identity(&app);
headroom::simulate_available(Some(1 << 20));
let refused = app.save_as(Some("big.psd".into()));
let refused_automation = app.save_automation(Some("auto.psd".into()));
headroom::simulate_available(None);
let err = refused.unwrap_err();
assert!(err.contains("Not enough memory to save \u{201c}big.psd\u{201d}"), "{err}");
assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}");
let automation_err = refused_automation.unwrap_err();
assert!(automation_err.contains("Not enough memory to save \u{201c}auto.psd\u{201d}"), "{automation_err}");
assert_eq!(*writes.borrow(), 0, "nothing was encoded or written");
assert_eq!(identity(&app), before, "the document is unsaved and unchanged");
app.save_as(Some("big.psd".into())).unwrap();
assert_eq!(*writes.borrow(), 1);
}
fn warnings(app: &PhotocraftApp) -> usize {
app.ui.notices.iter().filter(|n| n.title == photocraft_ui_egui::memory_warning::TITLE).count()
}
/// Going low posts one notice that stays; hovering low doesn't post again; recovering past the
/// higher threshold takes it down and arms the next warning.
#[test]
fn the_low_memory_warning_posts_once_and_rearms_with_hysteresis() {
let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
use photocraft_ui_egui::memory_warning::{REARM_ABOVE, WARN_BELOW, tick};
let mut app = app(Rc::new(RefCell::new(0)));
let mut now = 0.0;
let mut look = |app: &mut PhotocraftApp, available: Option<u64>| {
headroom::simulate_available(available);
now += 2.5;
tick(app, now);
};
look(&mut app, None);
assert_eq!(warnings(&app), 0, "plenty of memory (and always so on the desktop)");
look(&mut app, Some(WARN_BELOW - 1));
assert_eq!(warnings(&app), 1);
assert!(app.ui.notices.iter().all(|n| n.sticky), "stays until dismissed");
look(&mut app, Some(WARN_BELOW - 1));
look(&mut app, Some(WARN_BELOW + 1));
assert_eq!(warnings(&app), 1, "no second notice while it stays low");
look(&mut app, Some(REARM_ABOVE));
assert_eq!(warnings(&app), 0, "recovered: the stale notice is taken down");
look(&mut app, Some(WARN_BELOW - 1));
assert_eq!(warnings(&app), 1, "low again: warned again");
// Checks are two seconds apart at most.
headroom::simulate_available(None);
app.ui.notices.clear();
headroom::simulate_available(Some(0));
tick(&mut app, now + 0.5);
assert_eq!(warnings(&app), 0);
headroom::simulate_available(None);
}
#[test]
fn no_warning_without_a_document() {
let _turn = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut app = PhotocraftApp::new(Session::new(), Services::default());
headroom::simulate_available(Some(0));
photocraft_ui_egui::memory_warning::tick(&mut app, 100.0);
headroom::simulate_available(None);
assert_eq!(warnings(&app), 0);
}
+3 -1
View File
@@ -600,7 +600,9 @@ trunk serve --release # dev server on http://127.0.0.1:8765
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Some static hosts refuse a file that large (Cloudflare's per-file cap is 25 MiB), so the release site doesn't hold it as one file: `packaging/web/package.sh` gzips it, cuts it into parts of at most 20 MiB under `app/`, and ships the page of `apps/photocraft-web-boot`, a small module that fetches the parts, has the browser inflate them (or inflates them itself where the browser can't) and passes the stream to the app's own loader (issue #5302). The script fails if any file in the site is over 24 MiB; the wasm's own size is no longer limited, but it is still what every visitor downloads, so watch it. Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and file-size checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `apps/photocraft-web-boot`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the wasm's size and the site's largest file in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. `?free-mb=N` is a diagnostic: the memory gates and the low-memory warning behave as if only N MiB could be allocated, so they can be checked in a browser without gigabytes of images (for example `?free-mb=300`, then Place a large image, or `?free-mb=400` for the warning).
Running out of memory in the browser: a failed allocation aborts the WebAssembly module, and stable Rust can't turn that into an error, so the page froze on its last frame. Three layers now cover it (#10024). **Gate:** Place, Image Size, a smart-object re-render and every save or export (Save, Save a Copy, Export As, layer and batch exports) ask `photocraft_raster::headroom::ensure` first (a trial `try_reserve_exact` of the operation's estimated need plus 128 MiB, freed at once; always passes off the web), and refuse with an error naming the operation and the need when it won't fit (`photocraft_engine::memory_gate`, estimates per operation; `photocraft_io::estimate_export_bytes` for saves). **Warn:** `memory_warning` posts a sticky notice when linear memory is within 512 MiB of the 4 GiB wasm32 ceiling (`headroom::below` reads the memory size and allocates nothing, since a periodic trial reservation would grow memory that never shrinks), and re-arms at or above 768 MiB in simulations. Real wasm linear memory normally does not shrink, so the warning cannot normally re-arm. A PSD/PSB source's estimate adds a canvas-sized surface per layer (the layer count is read from its layer info, without decoding); one that isn't 8-bit RGB or grayscale, or whose sections can't be read, has no safe bound and is refused before Place or smart-object rendering on the web. **Backstop:** if the module still traps, `index.html` holds a hidden `#photocraft_stopped` message that `web.rs` reveals (the `error` and `unhandledrejection` events), saying whether it ran out of memory or hit an internal error (a panic's message is kept by a chained panic hook), with a Reload link; the "Leave site?" prompt is skipped then.
To build and serve the web app entirely in Docker, run from the repository root:
+13
View File
@@ -444,12 +444,25 @@ what an agent can't finish alone.
launch, #5485), in-app bug report (DIST-220-3), Flathub (#173),
starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with
OpenCore (#1859).
- **Progress (2026-10-29):** the web build no longer freezes silently when it runs out of memory
(#10024). A failed allocation aborts the WebAssembly module and can't be caught, so Place, Image
Size, a smart-object re-render and Save first ask the allocator whether their estimated need
fits (`photocraft_raster::headroom`, `photocraft_engine::memory_gate`) and refuse with an error
that says what to do; a standing warning shows below 512 MiB; and a trap that still happens
shows a "PhotoCraft stopped" message with a Reload link. Still open: the decoded originals that
accumulate (#10030), Place decoding on the UI thread (#10031), Image Size re-rendering every
smart object at once (#10032), and recovering the document after a crash (#6615). Not every
allocation is gated, so a trap is still possible.
A layered PSD/PSB source is estimated as a canvas-sized surface per layer (the decoder keeps
one per layer; layers larger than the canvas are not counted), and one that isn't 8-bit RGB or
grayscale is refused on the web before Place or smart-object rendering.
- **Estimate:** 30–60 h. **Human:** signing certificates.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | G17: web build memory handling (#10024): up-front memory checks for Place, Image Size, smart-object re-render and Save; a low-memory warning; a stopped message when the module traps; PSD save reserves its whole output; no estimate changed |
| 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged |
| 2026-11-02 | minor | Action recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and places point text by canvas percentage; paragraph boxes remain absolute (AUTO-219-1, #4514); parity percentages unchanged |
| 2026-11-01 | minor | Action recording stores portable layer targets and whole-text Type steps where unambiguous (AUTO-219-1, #4514); duplicate names remain ambiguous; parity percentages unchanged |
+2 -1
View File
@@ -14,6 +14,7 @@ the measured checklists are [`scorecard.md`](scorecard.md) (`cargo xtask scoreca
| Number | Value | Kind |
|---|---|---|
| 2026-10-29 | minor | Stability row: web build memory handling (#10024), memory checks before big operations, a low-memory warning and a stopped page for a trap; no percentage or estimate changed |
| **Feature breadth** (does each Photoshop feature exist?) | **~76%** | partly measured (menus, tools, panels, formats), weights below |
| **Ready for real work** (could a professional replace Photoshop with it?) | **~45%** (range 40–50%) | estimated, weights and evidence below |
| **Mainstream practitioner** (typical pro, weekly areas only) | **~43%** (38–48%) | estimated, [method](#mainstream-practitioner-43) |
@@ -79,7 +80,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch
| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 11 of 183 preferences do nothing (scorecard, 2026-11-01; was 12 of 183); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) |
| File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) |
| Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) |
| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) |
| Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; web build: Place, Image Size, smart-object re-render and Save are checked against available memory, a low-memory warning and a "stopped" page replace a silent freeze (2026-10-29, #10024); but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) |
| Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) |
| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) |
| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); Help › Check for Updates plus an opt-out check once per launch (DIST-220-1 partial, no periodic recheck) | [scorecard.md](scorecard.md#distribution) |