Files
figcraft/crates/render/src/images.rs
T

392 lines
16 KiB
Rust

//! Decoded image cache (content hash + filters + colour profile → 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.
//!
//! 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;
pub(crate) type Key = (String, [i32; 7], ColorProfile);
/// The largest image side decoded.
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).
const MAX_CACHED_BYTES: u64 = 512 << 20;
/// The most entries each decoded-image cache holds.
const MAX_CACHED_IMAGES: usize = 256;
/// 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
}
}
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 (max 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 (max 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 at most `max_side` wide and tall whose decoded pixels take at most
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(max_side);
limits.max_image_height = Some(max_side);
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 img = image::DynamicImage::from_decoder(dec).ok()?;
match source.convert(&img, target) {
Some(rgba) => Some((premultiplied(&rgba)?, false)),
None => Some((premultiplied(&img.to_rgba8())?, true)),
}
}
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>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p);
}
let (mut p, srgb) = decode_in(&data?.bytes, profile)?;
// 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).
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
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)
}