Files
figcraft/crates/render/src/images.rs
T
BFlat e32f8f2e07 M8: large images on the canvas draw from a smaller copy (#1483) (#1518)
* M8: large images on the canvas draw from a smaller copy (#1483)

An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).

* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
2026-10-29 18:14:18 +07:00

452 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Decoded image cache (content hash + filters + colour profile + largest side → premultiplied
//! pixmap). Images are converted from their own colour profile (see [`crate::image_profile`]; sRGB
//! when they have none) into the document's space, once per image and profile.
//!
//! The canvas draws a large image shown smaller than its pixels from a copy scaled down to one of
//! [`DISPLAY_SIDES`] (see [`shown`]), so several of them stay decoded within the cache's budget
//! (#1483); exports and every other render draw from the image itself ([`get`]).
//!
//! Animated GIFs keep their original bytes like any image; their first frame is the image
//! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on
//! first use (capped by [`MAX_FRAMES`] and [`MAX_ANIMATION_BYTES`]) and picks the one due.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::{ColorProfile, ImageFilters, PaintKind};
use figcraft_doc::{Document, ImageData, NodeId};
use tiny_skia::Pixmap;
/// Image, filters, colour profile and the largest side it was decoded at.
pub(crate) type Key = (String, [i32; 7], ColorProfile, u32);
/// The largest side of a decoded image: larger images are scaled down to fit.
const MAX_SIDE: u32 = 16_384;
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
/// larger image is refused from its header).
const MAX_DECODED_BYTES: u64 = 512 << 20;
/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held
/// alone).
pub(crate) const MAX_CACHED_BYTES: u64 = 512 << 20;
/// The most entries each decoded-image cache holds.
const MAX_CACHED_IMAGES: usize = 256;
/// The largest sides of the copies the canvas draws larger images from while they show smaller
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
/// together, and 42 MB each at 4096 (168 MB at 8192).
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
/// the images on screen stay while they fit.
#[derive(Default)]
pub(crate) struct PixmapCache {
/// Each pixmap and when it was last used.
map: HashMap<Key, (Arc<Pixmap>, u64)>,
bytes: u64,
tick: u64,
}
impl PixmapCache {
pub(crate) fn get(&mut self, key: &Key) -> Option<Arc<Pixmap>> {
self.tick += 1;
let tick = self.tick;
self.map.get_mut(key).map(|(p, used)| {
*used = tick;
p.clone()
})
}
/// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone).
pub(crate) fn insert(&mut self, key: Key, p: Arc<Pixmap>, max_bytes: u64) {
let size = p.data().len() as u64;
self.remove(&key);
while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES {
let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break };
self.remove(&oldest);
}
self.tick += 1;
self.map.insert(key, (p, self.tick));
self.bytes = self.bytes.saturating_add(size);
}
fn remove(&mut self, key: &Key) {
if let Some((p, _)) = self.map.remove(key) {
self.bytes = self.bytes.saturating_sub(p.data().len() as u64);
}
}
/// The bytes of pixels held.
#[cfg(test)]
pub(crate) fn bytes(&self) -> u64 {
self.bytes
}
}
/// The sides image `hash` is decoded at in the cache (any filters and profile), smallest first.
#[cfg(test)]
pub(crate) fn cached_sides(hash: &str) -> Vec<u32> {
let c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut sides: Vec<u32> = c.map.keys().filter(|k| k.0 == hash).map(|k| k.3).collect();
sides.sort_unstable();
sides
}
fn cache() -> &'static Mutex<PixmapCache> {
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PixmapCache::default()))
}
fn fkey(f: &ImageFilters) -> [i32; 7] {
let q = |v: f32| (v * 1000.0).round() as i32;
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
}
/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side).
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p)
}
/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
/// `target` conversion is still to do).
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
}
/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused
/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and
/// tall.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
if figcraft_format::jpeg::finer_than_first(bytes) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max_bytes);
reader.limits(limits);
let mut dec = reader.into_decoder().ok()?;
if dec.total_bytes() > max_bytes {
return None;
}
let source = crate::image_profile::Source::read(bytes, &mut dec);
let mut img = image::DynamicImage::from_decoder(dec).ok()?;
if img.width() > max_side || img.height() > max_side {
// `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels).
img = img.thumbnail(max_side, max_side);
}
match source.convert(&img, target) {
Some(rgba) => Some((premultiplied(&rgba)?, false)),
None => Some((premultiplied(&img.to_rgba8())?, true)),
}
}
/// Whether image `hash`, decoded as `pm`, has no transparent pixels (filters and colour profiles
/// keep alpha). Scanned once per image.
pub(crate) fn opaque(hash: &str, pm: &Pixmap) -> bool {
static C: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let c = C.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(o) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(hash) {
return *o;
}
let o = pm.pixels().iter().all(|p| p.alpha() == u8::MAX);
let mut c = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 4096 {
c.clear();
}
c.insert(hash.to_string(), o);
o
}
fn profile_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
static C: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The name of image `hash`'s own colour profile (empty for an unnamed one); `None` when unknown,
/// and it's taken as sRGB. Cached.
pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option<String> {
if let Some(n) = profile_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(hash) {
return n.clone();
}
let name = crate::image_profile::Source::of(&data?.bytes).name;
let mut c = profile_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
c.clear();
}
c.insert(hash.to_string(), name.clone());
name
}
/// Straight RGBA → a premultiplied pixmap (max 16k per side).
fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let mut p = Pixmap::new(w, h)?;
for (dst, src) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().chunks_exact(4)) {
let a = u32::from(src.get(3).copied().unwrap_or(255));
let pm = |c: u8| ((u32::from(c) * a + 127) / 255) as u8;
dst.copy_from_slice(&[
pm(src.first().copied().unwrap_or(0)),
pm(src.get(1).copied().unwrap_or(0)),
pm(src.get(2).copied().unwrap_or(0)),
a as u8,
]);
}
Some(p)
}
/// The decoded (and filtered) image for `hash` in the colour space `profile`, cached.
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
get_within(hash, data, filters, profile, MAX_SIDE)
}
/// [`get`] for the canvas, where the image shows `side` device pixels on its longer side: an image
/// larger than one of [`DISPLAY_SIDES`] that holds `side` comes scaled down to that side (cached
/// apart), else as [`get`] gives it. The image's size is the one `data` records (none: the image).
pub fn shown(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, side: f64) -> Option<Arc<Pixmap>> {
get_within(hash, data, filters, profile, display_side(data.map_or(0, |d| d.width.max(d.height)), side))
}
/// The largest side to decode an image `long` pixels on its longer side at, to show `side` device
/// pixels long: the smallest of [`DISPLAY_SIDES`] that holds `side` and is smaller than the image,
/// else the image's own ([`MAX_SIDE`]).
pub(crate) fn display_side(long: u32, side: f64) -> u32 {
DISPLAY_SIDES.into_iter().find(|s| f64::from(*s) >= side && *s < long).unwrap_or(MAX_SIDE)
}
/// [`get`] scaled down to at most `max_side` wide and tall.
fn get_within(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, max_side: u32) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile, max_side);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p);
}
let (mut p, srgb) = decode_within(&data?.bytes, profile, max_side, MAX_DECODED_BYTES)?;
// Images with their own profile are in `profile` already (sRGB → sRGB is the identity).
finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb });
let p = Arc::new(p);
cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES);
Some(p)
}
/// The filters, then the conversion into the document's colour space.
fn finish(p: &mut Pixmap, filters: &ImageFilters, profile: ColorProfile) {
if !filters.is_identity() {
apply_filters(p, filters);
}
ColorProfile::Srgb.conversion_to(profile).apply_pixels(p.data_mut());
}
/// The most frames of an animated GIF that play.
pub const MAX_FRAMES: usize = 500;
/// The most decoded bytes an animated GIF's frames take (frames past it don't play).
pub const MAX_ANIMATION_BYTES: u64 = 128 << 20;
/// The most decoded bytes the cached animations take together.
const MAX_CACHED_ANIMATION_BYTES: u64 = 256 << 20;
/// An animated GIF's frames (whole, composited), filtered and in one colour space.
pub struct Animation {
frames: Vec<Arc<Pixmap>>,
/// When each frame ends, in seconds from the start of a loop.
ends: Vec<f64>,
}
impl Animation {
/// The frame showing `t` seconds in (the animation loops) and the seconds until the next one.
pub fn at(&self, t: f64) -> (usize, f64) {
let total = self.ends.last().copied().unwrap_or(0.0);
if !t.is_finite() || t <= 0.0 || total <= 0.0 {
return (0, self.ends.first().copied().unwrap_or(f64::INFINITY));
}
let t = t.rem_euclid(total);
let i = self.ends.partition_point(|e| *e <= t).min(self.ends.len().saturating_sub(1));
(i, self.ends.get(i).map_or(f64::INFINITY, |e| e - t))
}
pub fn len(&self) -> usize {
self.frames.len()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
fn bytes(&self) -> u64 {
self.frames.iter().map(|f| f.data().len() as u64).sum()
}
}
/// A GIF frame's delay in seconds. Delays under 20 ms play as 100 ms, as browsers do (many GIFs
/// say 0 and mean "the default").
pub fn frame_secs(numer: u32, denom: u32) -> f64 {
let ms = if denom == 0 { 0.0 } else { f64::from(numer) / f64::from(denom) };
if ms < 20.0 { 0.1 } else { ms / 1000.0 }
}
/// Every frame of an animated GIF with its delay; `None` for other images, stills and GIFs too
/// big for one frame within [`MAX_ANIMATION_BYTES`]. A damaged frame ends the animation there.
/// shortcut: GIFs always loop (their repeat count is ignored); read it if a one-shot GIF matters.
pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
use image::{AnimationDecoder, ImageDecoder};
if image::guess_format(bytes).ok()? != image::ImageFormat::Gif {
return None;
}
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let (w, h) = dec.dimensions();
let frame_bytes = u64::from(w) * u64::from(h) * 4;
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
let mut out = Vec::new();
for f in dec.into_frames().take(most) {
let Ok(f) = f else { break };
let (n, d) = f.delay().numer_denom_ms();
out.push((premultiplied(f.buffer())?, frame_secs(n, d)));
}
(out.len() > 1).then_some(out)
}
type AnimKey = (String, [i32; 7], ColorProfile);
type AnimCache = HashMap<AnimKey, Option<Arc<Animation>>>;
fn anim_cache() -> &'static Mutex<AnimCache> {
static C: OnceLock<Mutex<AnimCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The animated GIF `hash`'s frames for `filters` and `profile`, decoded on first use and cached;
/// `None` for anything that doesn't move.
pub fn animation(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Animation>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(a) = anim_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return a.clone();
}
let a = decode_frames(&data?.bytes).map(|frames| {
let mut end = 0.0;
let (frames, ends) = frames
.into_iter()
.map(|(mut p, secs)| {
finish(&mut p, filters, profile);
end += secs;
(Arc::new(p), end)
})
.unzip();
Arc::new(Animation { frames, ends })
});
let mut c = anim_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let held: u64 = c.values().flatten().map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > MAX_CACHED_ANIMATION_BYTES || c.len() > 256 {
c.clear();
}
c.insert(key, a.clone());
a
}
/// The frame an animated GIF shows `t` seconds in, when that isn't its first frame (the still
/// image every other path draws).
pub fn frame_at(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, t: f64) -> Option<Arc<Pixmap>> {
if t.is_nan() || t <= 0.0 {
return None;
}
let a = animation(hash, data, filters, profile)?;
match a.at(t).0 {
0 => None,
i => a.frames.get(i).cloned(),
}
}
/// Where the animated GIFs in `root`'s visible fills and strokes are `t` seconds in: a number for
/// the frames they show (a cache key) and the seconds until one of them changes. `None` when
/// nothing there moves.
/// shortcut: text runs with their own image fills aren't looked at; add them if anyone animates text.
pub fn animation_clock(doc: &Document, root: NodeId, t: f64) -> Option<(u64, f64)> {
let mut clock: Option<(u64, f64)> = None;
for n in doc.subtree(root).into_iter().filter_map(|id| doc.node(id)).filter(|n| n.visible) {
for p in n.fills.iter().chain(&n.strokes).filter(|p| p.visible) {
if let PaintKind::Image(img) = &p.kind
&& let Some(a) = animation(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile)
{
let (i, next) = a.at(t);
let (key, soonest) = clock.unwrap_or((0, f64::INFINITY));
clock = Some((key.wrapping_mul(31).wrapping_add(i as u64 + 1), soonest.min(next)));
}
}
}
clock
}
/// Image adjustments (approximations of exposure, contrast, saturation, temperature, tint,
/// highlights and shadows) on a premultiplied pixmap.
pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
let exposure = 2f32.powf(f.exposure.clamp(-1.0, 1.0) * 2.0);
let contrast = 1.0 + f.contrast.clamp(-1.0, 1.0);
let sat = 1.0 + f.saturation.clamp(-1.0, 1.0);
let temp = f.temperature.clamp(-1.0, 1.0) * 0.15;
let tint = f.tint.clamp(-1.0, 1.0) * 0.15;
let hi = f.highlights.clamp(-1.0, 1.0) * 0.3;
let sh = f.shadows.clamp(-1.0, 1.0) * 0.3;
for px in p.data_mut().as_chunks_mut::<4>().0 {
let a = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0;
if a <= 0.0 {
continue;
}
let un = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0 / a;
let (mut r, mut g, mut b) = (un(0), un(1), un(2));
r *= exposure;
g *= exposure;
b *= exposure;
r = (r - 0.5) * contrast + 0.5;
g = (g - 0.5) * contrast + 0.5;
b = (b - 0.5) * contrast + 0.5;
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
r = l + (r - l) * sat;
g = l + (g - l) * sat;
b = l + (b - l) * sat;
r += temp;
b -= temp;
g += tint;
let l = (0.2126 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 1.0);
let adj = hi * l * l + sh * (1.0 - l) * (1.0 - l);
r += adj;
g += adj;
b += adj;
let q = |c: f32| (c.clamp(0.0, 1.0) * a * 255.0).round() as u8;
let (qr, qg, qb) = (q(r), q(g), q(b));
if let Some(s) = px.get_mut(0..3) {
s.copy_from_slice(&[qr, qg, qb]);
}
}
}
/// Read an image's pixel size without keeping the decode.
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
reader.into_dimensions().ok()
}
/// `pm` with a transparent 1-pixel border.
pub fn pad(pm: &Pixmap) -> Option<Pixmap> {
let mut out = Pixmap::new(pm.width() + 2, pm.height() + 2)?;
out.draw_pixmap(1, 1, pm.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), None);
Some(out)
}
fn padded_cache() -> &'static Mutex<PixmapCache> {
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PixmapCache::default()))
}
/// `pm` with a transparent 1-pixel border (cached; a smaller copy of the image apart).
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile, pm.width().max(pm.height()));
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p);
}
let out = Arc::new(pad(pm)?);
padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES);
Some(out)
}