mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
The canvas drew an image shown smaller than its pixels from a 4096 or 8192 px copy, and an image of 4096 px or less from itself, whatever its size on screen. A page of a few hundred photos zoomed out then asks the decoded-image cache (512 MB) for several GB: every render decoded them again, and a zoom out took seconds with the canvas empty around the old frame. The copies now also come at 256, 512, 1024 and 2048 px, the smallest that holds the size shown. Measured on a file with 237 images (median 5.8 MP), canvas options, 2352 x 2090 view, M4 Max: | zoom | before | after, first | after, again | |---|---|---|---| | 10% | 0.25 s | 0.12 s | 6 ms | | 4% | 0.88 s | 0.46 s | 7 ms | | 1% | 3.4 s | 0.9 s | 4 ms | Peak memory over the run went from 3.9 GB to 2.3 GB. Exports and views at 100% and closer still draw from the image itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
5.1 KiB
Rust
117 lines
5.1 KiB
Rust
//! Large images on the canvas (#1483): a large image shown smaller than its pixels is drawn from a
|
||
//! copy no larger than it shows, so two of them stay decoded together, while exports and close-up
|
||
//! views draw from the image itself.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use figcraft_color::{ImageScaleMode, Paint};
|
||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
|
||
use tiny_skia::Pixmap;
|
||
|
||
use crate::images::{self, MAX_CACHED_BYTES};
|
||
use crate::{Options, View, render_view};
|
||
|
||
const W: u32 = 6000;
|
||
const H: u32 = 8;
|
||
|
||
/// A `W` × `H` PNG of one-pixel black and white columns, black first unless `phase` is odd.
|
||
fn stripes(phase: u32) -> Vec<u8> {
|
||
let img = image::RgbaImage::from_fn(W, H, |x, _| image::Rgba(if (x + phase).is_multiple_of(2) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||
let mut out = Vec::new();
|
||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
out
|
||
}
|
||
|
||
/// Two `W` × `H` images at their own size, one under the other (keys starting with `tag`: the
|
||
/// cache is shared by every test).
|
||
fn doc(tag: &str) -> (Document, NodeId, [String; 2]) {
|
||
let mut d = Document::new("large images");
|
||
let page = d.first_page().unwrap();
|
||
let keys = [format!("{tag}-a"), format!("{tag}-b")];
|
||
for (i, key) in (0u32..).zip(&keys) {
|
||
d.images.insert(key.clone(), ImageData { bytes: Arc::new(stripes(i)), width: W, height: H });
|
||
let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, f64::from(i * 2 * H), f64::from(W), f64::from(H)).unwrap();
|
||
d.get_mut(r).unwrap().fills = vec![Paint::image(key, ImageScaleMode::Fill)];
|
||
}
|
||
(d, page, keys)
|
||
}
|
||
|
||
fn canvas() -> Options {
|
||
Options { lod: true, magnified_image_pixels: true, ..Default::default() }
|
||
}
|
||
|
||
/// The first `W` units of both rows at `scale`.
|
||
fn view(scale: f64) -> View {
|
||
View { origin: (0.0, 0.0), scale, width: (f64::from(W) * scale).ceil() as u32, height: (f64::from(4 * H) * scale).ceil() as u32 }
|
||
}
|
||
|
||
/// The canvas's copy of image `key` of `d` shown `side` device pixels long.
|
||
fn shown(d: &Document, key: &str, side: f64) -> Arc<Pixmap> {
|
||
images::shown(key, d.images.get(key), &Default::default(), d.color_profile, side).unwrap()
|
||
}
|
||
|
||
/// Whether row `y` of `p` shows one-pixel columns, black at even `x` when `black_first`, `each`
|
||
/// device pixels wide.
|
||
fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
|
||
(0..200).chain(4000..4200).all(|x| {
|
||
let red = p.pixel(x, y).unwrap().red();
|
||
let black = (x / each) % 2 == u32::from(!black_first);
|
||
if black { red < 8 } else { red > 247 }
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
|
||
let (d, page, keys) = doc("zoomed-out");
|
||
render_view(&d, page, &view(0.25), &canvas()).unwrap();
|
||
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
|
||
for (k, p) in keys.iter().zip(&first) {
|
||
assert_eq!((p.width(), p.height()), (2048, 3), "{k}");
|
||
assert_eq!(images::cached_sides(k), vec![2048], "{k}: only its copy is decoded");
|
||
}
|
||
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
|
||
render_view(&d, page, &view(0.25), &canvas()).unwrap();
|
||
for (k, p) in keys.iter().zip(&first) {
|
||
assert!(Arc::ptr_eq(p, &shown(&d, k, 1500.0)), "{k} was decoded again");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn exports_and_close_ups_draw_from_the_image_itself() {
|
||
let (d, page, keys) = doc("close-up");
|
||
// An export at 1x, and the canvas at 100% and 200%, show every pixel column.
|
||
let export = render_view(&d, page, &view(1.0), &Options::default()).unwrap();
|
||
assert!(sharp_stripes(&export, 4, true, 1) && sharp_stripes(&export, 2 * H + 4, false, 1));
|
||
let at_100 = render_view(&d, page, &view(1.0), &canvas()).unwrap();
|
||
assert!(sharp_stripes(&at_100, 4, true, 1) && sharp_stripes(&at_100, 2 * H + 4, false, 1));
|
||
let at_200 = render_view(&d, page, &view(2.0), &canvas()).unwrap();
|
||
assert!(sharp_stripes(&at_200, 8, true, 2));
|
||
assert_eq!(images::cached_sides(&keys[0]), vec![16_384], "the image itself, as exports draw it");
|
||
}
|
||
|
||
#[test]
|
||
fn the_copy_is_the_smallest_that_holds_what_shows() {
|
||
let side = |long, shown| images::display_side(long, shown);
|
||
assert_eq!(side(6000, 1500.0), 2048);
|
||
assert_eq!(side(16_000, 4096.0), 4096);
|
||
assert_eq!(side(16_000, 4097.0), 8192);
|
||
assert_eq!(side(4096, 100.0), 256);
|
||
assert_eq!(side(3000, 20.0), 256);
|
||
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
|
||
assert_eq!(side(16_000, 9000.0), 16_384);
|
||
assert_eq!(side(6000, 5000.0), 16_384);
|
||
assert_eq!(side(200, 100.0), 16_384);
|
||
// An unknown size or a meaningless scale: the image itself.
|
||
assert_eq!(side(0, 100.0), 16_384);
|
||
assert_eq!(side(16_000, f64::NAN), 16_384);
|
||
}
|
||
|
||
#[test]
|
||
fn two_16k_images_fit_the_cache_as_copies_but_not_in_full() {
|
||
// 16 000 × 10 000 (#1483's size class): 640 MB each in full, 42 MB at 4096 and 168 MB at 8192.
|
||
let bytes = |w: u64, h: u64| w * h * 4;
|
||
assert!(2 * bytes(16_000, 10_000) > MAX_CACHED_BYTES);
|
||
assert!(2 * bytes(8192, 5120) <= MAX_CACHED_BYTES);
|
||
assert!(2 * bytes(4096, 2560) <= MAX_CACHED_BYTES);
|
||
}
|