mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
* Use craft-psd from craft-libs; remove crates/psd * Regenerate the scorecard (28 crate roots) * Take craft-psd at craft-libs 55d120f2 * Regenerate lockfile for current shared format crates * Pin shared format crates to current craft-libs main * Match brush import cap to shared decoder budget * xtask: update layer fixtures after PSD extraction
262 lines
11 KiB
Rust
262 lines
11 KiB
Rust
//! 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 craft_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 craft_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);
|
|
}
|