fix: bound the image decode transient and keep native picks on disk

Follow-up to the #1717 port:

- The downsample tier holds the decoded RGBA, the resize's vertical-pass
  intermediate (width x new height in RGBA f32) and the output at once;
  that sum now has to fit the decode budget, or the image is refused up
  front instead of overshooting it several times over. The native
  budget drops from 4 GiB to 2 GiB.
- A native IMAGE pick decodes from the picked file instead of copying
  its bytes into the session registry, so an edited file reloads and the
  bytes are not held for the session. The web keeps registering them.
- Reword a plot-preview comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-12 12:02:06 +03:00
1 parent fb674fc45e
commit ce6bd2c73e
3 files changed
+37 -15

No files matched your search

+13 -7
View File
@@ -2038,11 +2038,11 @@ pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, Str
/// Show a file-open dialog for raster images and decode the selected file.
/// Returns `(path, pixel_width, pixel_height)` or an error string.
///
/// The bytes are read through the dialog handle and registered against the
/// returned path (name-only on the web — there is no filesystem behind it),
/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and
/// the entity the IMAGE command commits resolves through the same registry
/// instead of a `image::open` on a path that only exists natively.
/// On the web the bytes are read through the dialog handle and registered
/// against the returned name-only path (there is no filesystem behind it),
/// mirroring the PDF-underlay pick; decoding then comes from those bytes, and
/// the entity the IMAGE command commits resolves through the same registry.
/// Native decodes straight from the picked file.
///
/// The filter names exactly the formats the `image` crate features build
/// (Cargo.toml) — png/jpeg/bmp/tiff/webp/gif — and `io::web_drop` accepts the
@@ -2061,13 +2061,19 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
.await
.ok_or_else(|| "Cancelled".to_string())?;
let path = crate::sys::handle_path(&handle);
let bytes = std::sync::Arc::new(handle.read().await);
let reference = path.to_string_lossy().into_owned();
let short = std::path::Path::new(&reference)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| reference.clone());
crate::scene::model::image_model::register_source(&reference, bytes);
// Web only: the name-only path has nothing behind it, so the bytes go
// into the registry. Native decodes from the file itself, so an edited
// image reloads and the bytes are not held for the session.
#[cfg(target_arch = "wasm32")]
crate::scene::model::image_model::register_source(
&reference,
std::sync::Arc::new(handle.read().await),
);
let decoded = crate::scene::model::image_model::resolve_image(&reference).ok_or_else(|| {
// An over-budget decode refusal explains itself (dims + limit);
// anything else stays the generic unsupported/corrupt message.
+2 -2
View File
@@ -9,8 +9,8 @@
//
// The web build has no system printers: `preview_page_pdf` /
// `preview_pages_pdf` render the same PDF bytes, rasterise them, and show
// the pages in an in-page overlay — the way AutoCAD previews plot output on
// screen rather than handing a file to an external viewer.
// the pages in an in-page overlay — the plot preview stays on screen
// rather than handing a file to an external viewer.
#[cfg(not(target_arch = "wasm32"))]
use crate::io::pdf_export;
+22 -6
View File
@@ -721,10 +721,11 @@ fn decode_reference(path: &str) -> Option<DecodedImage> {
/// exceed it run with `image::Limits` pinned to the budget, so the decoder
/// errors instead of ballooning the heap; this module turns that error into a
/// localized refusal (see `refusal_message`) — the pixels are simply not
/// available at full size. Desktops have RAM to spare and rarely hit theirs;
/// available at full size. Desktops have more headroom than a tab, but the
/// whole decode transient lands at once, so it stays well below typical RAM;
/// wasm lives inside a browser tab whose heap the decode must not pop.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DECODE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
const MAX_DECODE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
/// wasm: 512 MB of decoded RGBA — comfortably inside a tab's heap.
#[cfg(target_arch = "wasm32")]
const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
@@ -836,11 +837,23 @@ fn tier_for_native(
.saturating_mul(codec_factor)
> max_decode_bytes
{
return DecodeTier::OverBudget;
}
if pixels <= max_display_pixels {
return DecodeTier::Full;
}
// The downsample holds the decoded RGBA, the resize's vertical-pass
// intermediate (width × new height in RGBA f32, 16 B/px) and the output
// at once, so that sum must fit the budget too.
let (target_w, target_h) = downsample_to_cap(width, height, max_display_pixels);
let resize_peak = pixels
.saturating_mul(4)
.saturating_add((width as u64).saturating_mul(target_h as u64).saturating_mul(16))
.saturating_add(target_w as u64 * target_h as u64 * 4);
if resize_peak > max_decode_bytes {
DecodeTier::OverBudget
} else if pixels > max_display_pixels {
DecodeTier::Downsample
} else {
DecodeTier::Full
DecodeTier::Downsample
}
}
@@ -1524,7 +1537,10 @@ mod tests {
assert_eq!(tier_for(64, 64, 1_000_000, 10_000), DecodeTier::Full);
// Pixels over the display cap while RGBA stays within the decode
// budget: decode, then downsample before storing.
assert_eq!(tier_for(400, 400, 1_000_000, 10_000), DecodeTier::Downsample);
assert_eq!(tier_for(400, 400, 2_000_000, 10_000), DecodeTier::Downsample);
// …unless the resize transient (decoded + f32 intermediate + output)
// is what overflows the budget.
assert_eq!(tier_for(400, 400, 1_000_000, 10_000), DecodeTier::OverBudget);
// RGBA over the decode budget.
assert_eq!(tier_for(2_000, 2_000, 1_000_000, 10_000), DecodeTier::OverBudget);
// Exact boundary counts stay in the lower tier (limits are ">").