mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
Fix #1544: Image Size refuses a resample past the raster budget; resampling writes bands as it goes (#1569)
image.imageSize accepted 64x48 -> 300000x300000 (90 billion pixels, each side within the 300000 px limit) and started allocating destination-sized f32 bands. The command now refuses a resampled canvas whose raster alone would pass the decoders' default allocation budget (8 GiB on 64-bit), with an actionable message, before touching the document; resolution-only changes (Resample off) are unaffected. The resampler also writes its bands into the output a group at a time instead of collecting every band first, so the f32 rows held at once are bounded by the thread count, not the destination size. Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA Co-authored-by: Claude <[email protected]>
This commit is contained in:
1 parent
f82a55f056
commit
9c554e2ead
2 files changed
+48
-7
No files matched your search
@@ -266,15 +266,23 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
|
||||
unpremultiply(&mut res, n, alpha);
|
||||
(Rect::new(dst.x0, dst.y0 + b0 as i32, dst.x1, dst.y0 + b1 as i32), res)
|
||||
};
|
||||
// Bands go into the output a group at a time, so the `f32` rows held at once are bounded by
|
||||
// the group, not by the whole destination (#1544).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let parts: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
bands.par_iter().map(run).collect()
|
||||
};
|
||||
let group = rayon::current_num_threads().max(1) * 2;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<(Rect, Vec<f32>)> = bands.iter().map(run).collect();
|
||||
for (r, d) in parts {
|
||||
out.write_region(r, &d);
|
||||
let group = 1;
|
||||
for chunk in bands.chunks(group) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let parts: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
chunk.par_iter().map(run).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let parts: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
|
||||
for (r, d) in parts {
|
||||
out.write_region(r, &d);
|
||||
}
|
||||
}
|
||||
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
|
||||
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
|
||||
|
||||
@@ -108,6 +108,9 @@ fn parse_resample(s: &str) -> Option<Resample> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The largest raster Image Size resamples to: the decoders' default allocation budget.
|
||||
const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
|
||||
|
||||
/// Image → Image Size.
|
||||
fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
@@ -123,6 +126,20 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => (ow as f64, oh as f64),
|
||||
};
|
||||
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
|
||||
// Each side may reach 300000 px, but resampling writes real pixels: refuse a canvas whose
|
||||
// raster alone would pass the budget, before allocating any of it (#1544).
|
||||
let bytes = u64::from(nw).saturating_mul(u64::from(nh)).saturating_mul(d.doc.pixel_format().bytes_per_pixel() as u64);
|
||||
if resample.is_some() && (nw, nh) != (ow, oh) && bytes > MAX_RESAMPLE_BYTES {
|
||||
let mp = |w: u32, h: u32| u64::from(w) * u64::from(h) / 1_000_000;
|
||||
let max_mp = MAX_RESAMPLE_BYTES / d.doc.pixel_format().bytes_per_pixel().max(1) as u64 / 1_000_000;
|
||||
return Err(bad(
|
||||
"image.imageSize",
|
||||
format!(
|
||||
"{nw} x {nh} px is {} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size, or turn Resample off to change only the resolution",
|
||||
mp(nw, nh)
|
||||
),
|
||||
));
|
||||
}
|
||||
let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32);
|
||||
s.edit("Image Size", |doc, _| {
|
||||
if let Some(r) = dpi {
|
||||
@@ -574,6 +591,22 @@ mod tests {
|
||||
|
||||
/// The canvas border stays opaque after Image Size (it used to fade into transparency, so a
|
||||
/// Background or a 200 % export got a translucent frame), and a full selection stays full.
|
||||
/// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands
|
||||
/// without a budget. It is refused before any allocation and the document is untouched; a
|
||||
/// change of resolution only, and an ordinary resize, still work.
|
||||
#[test]
|
||||
fn image_size_refuses_a_raster_past_the_budget() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
let before = (doc(&s).size, s.active().unwrap().history.past_len());
|
||||
let err = s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000})).unwrap_err().to_string();
|
||||
assert!(err.contains("megapixels"), "{err}");
|
||||
assert_eq!((doc(&s).size, s.active().unwrap().history.past_len()), before);
|
||||
s.execute("image.imageSize", json!({"width": 300_000, "height": 300_000, "resample": "none", "resolution": 300})).unwrap();
|
||||
s.execute("image.imageSize", json!({"width": 128, "height": 96})).unwrap();
|
||||
assert_eq!(doc(&s).size, Size::new(128, 96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_keeps_canvas_edges_opaque() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
Reference in new issue
Block a user