rasterlottie 0.1.0

Pure Rust, headless Lottie rasterizer for deterministic server-side rendering
Documentation
#[cfg(feature = "images")]
use std::cell::RefCell;
#[cfg(not(feature = "images"))]
use std::mem::size_of_val;
use std::rc::Rc;

#[cfg(feature = "images")]
use base64::{Engine, engine::general_purpose};
#[cfg(feature = "images")]
use image::imageops::{self, FilterType};
#[cfg(feature = "images")]
use rustc_hash::FxHashMap;
#[cfg(not(feature = "images"))]
use tiny_skia::Pixmap;
#[cfg(feature = "images")]
use tiny_skia::{IntSize, Pixmap};

use super::renderer::ImageAssetResolver;
#[cfg(feature = "images")]
use crate::Asset;
use crate::{Animation, RasterlottieError};

#[cfg(feature = "images")]
#[derive(Debug, Default)]
pub(super) struct ImageAssetStore {
    entries: FxHashMap<String, LazyImageAssetEntry>,
}

#[cfg(not(feature = "images"))]
#[derive(Debug, Default)]
pub(super) struct ImageAssetStore;

#[cfg(feature = "images")]
#[derive(Debug)]
struct LazyImageAssetEntry {
    asset_index: usize,
    source: EncodedImageSource,
    decoded: RefCell<Option<Rc<Pixmap>>>,
}

#[cfg(feature = "images")]
#[derive(Debug)]
enum EncodedImageSource {
    EmbeddedDataUrl,
    EncodedBytes(Vec<u8>),
}

impl ImageAssetStore {
    #[cfg(feature = "images")]
    pub(super) fn get(
        &self,
        animation: &Animation,
        ref_id: &str,
    ) -> Result<Option<Rc<Pixmap>>, RasterlottieError> {
        let Some(entry) = self.entries.get(ref_id) else {
            return Ok(None);
        };

        if let Some(decoded) = entry.decoded.borrow().as_ref().cloned() {
            return Ok(Some(decoded));
        }

        let asset = animation.assets.get(entry.asset_index).ok_or_else(|| {
            RasterlottieError::InvalidImageAsset {
                id: ref_id.to_string(),
                detail: "image asset index is out of bounds".to_string(),
            }
        })?;
        let bytes = match &entry.source {
            EncodedImageSource::EmbeddedDataUrl => {
                let data_url =
                    asset
                        .image_data_url()
                        .ok_or_else(|| RasterlottieError::InvalidImageAsset {
                            id: asset.id.clone(),
                            detail: "embedded image asset is missing a data URL".to_string(),
                        })?;
                decode_data_url_bytes(asset, data_url)?
            }
            EncodedImageSource::EncodedBytes(bytes) => bytes.clone(),
        };
        let decoded = Rc::new(decode_image_bytes(asset, &bytes)?);
        *entry.decoded.borrow_mut() = Some(Rc::clone(&decoded));
        Ok(Some(decoded))
    }

    #[cfg(not(feature = "images"))]
    pub(super) fn get(
        &self,
        _animation: &Animation,
        ref_id: &str,
    ) -> Result<Option<Rc<Pixmap>>, RasterlottieError> {
        let _store_size = size_of_val(self);
        if ref_id.is_empty() {
            Ok(None)
        } else {
            Err(RasterlottieError::InvalidImageAsset {
                id: ref_id.to_string(),
                detail: "image support is disabled because the `images` feature is not enabled"
                    .to_string(),
            })
        }
    }
}

#[cfg(feature = "images")]
pub(super) fn resolve_image_assets(
    animation: &Animation,
    resolver: Option<&dyn ImageAssetResolver>,
) -> Result<ImageAssetStore, RasterlottieError> {
    let mut entries = FxHashMap::default();
    for (asset_index, asset) in animation.assets.iter().enumerate() {
        if !asset.is_image_asset() {
            continue;
        }

        let source = if asset.image_data_url().is_some() {
            EncodedImageSource::EmbeddedDataUrl
        } else if let Some(resolver) = resolver {
            EncodedImageSource::EncodedBytes(resolver.resolve_image_asset(asset)?.ok_or_else(
                || RasterlottieError::InvalidImageAsset {
                    id: asset.id.clone(),
                    detail: "image asset resolver did not return bytes".to_string(),
                },
            )?)
        } else {
            return Err(RasterlottieError::InvalidImageAsset {
                id: asset.id.clone(),
                detail: "only embedded data URL image assets are supported without a resolver"
                    .to_string(),
            });
        };

        entries.insert(
            asset.id.clone(),
            LazyImageAssetEntry {
                asset_index,
                source,
                decoded: RefCell::default(),
            },
        );
    }

    Ok(ImageAssetStore { entries })
}

#[cfg(not(feature = "images"))]
pub(super) fn resolve_image_assets(
    animation: &Animation,
    resolver: Option<&dyn ImageAssetResolver>,
) -> Result<ImageAssetStore, RasterlottieError> {
    if let Some(asset) = animation.assets.iter().find(|asset| asset.is_image_asset()) {
        return Err(RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: "image support is disabled because the `images` feature is not enabled"
                .to_string(),
        });
    }

    if resolver.is_some() {
        return Err(RasterlottieError::InvalidImageAsset {
            id: "<resolver>".to_string(),
            detail: "image support is disabled because the `images` feature is not enabled"
                .to_string(),
        });
    }

    Ok(ImageAssetStore)
}

#[cfg(feature = "images")]
fn decode_image_bytes(asset: &Asset, bytes: &[u8]) -> Result<Pixmap, RasterlottieError> {
    let mut rgba = image::load_from_memory(bytes)
        .map_err(|err| RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: format!("failed to decode image bytes: {err}"),
        })?
        .to_rgba8();

    let target_width = asset.width.unwrap_or_else(|| rgba.width());
    let target_height = asset.height.unwrap_or_else(|| rgba.height());
    if target_width == 0 || target_height == 0 {
        return Err(RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: "image asset dimensions must be non-zero".to_string(),
        });
    }

    if rgba.width() != target_width || rgba.height() != target_height {
        rgba = imageops::resize(&rgba, target_width, target_height, FilterType::Triangle);
    }

    premultiply_rgba(rgba.as_mut());
    let size = IntSize::from_wh(target_width, target_height).ok_or(
        RasterlottieError::InvalidCanvasSize {
            width: target_width,
            height: target_height,
        },
    )?;
    Pixmap::from_vec(rgba.into_vec(), size).ok_or_else(|| RasterlottieError::InvalidImageAsset {
        id: asset.id.clone(),
        detail: "failed to create image pixmap".to_string(),
    })
}

#[cfg(feature = "images")]
fn decode_data_url_bytes(asset: &Asset, data_url: &str) -> Result<Vec<u8>, RasterlottieError> {
    let Some((metadata, payload)) = data_url.split_once(',') else {
        return Err(RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: "embedded image asset is not a valid data URL".to_string(),
        });
    };
    if !metadata.starts_with("data:") {
        return Err(RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: "embedded image asset is not a data URL".to_string(),
        });
    }
    if !metadata.contains(";base64") {
        return Err(RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: "only base64-encoded data URLs are supported".to_string(),
        });
    }

    general_purpose::STANDARD
        .decode(payload.trim())
        .map_err(|err| RasterlottieError::InvalidImageAsset {
            id: asset.id.clone(),
            detail: format!("failed to decode base64 image data: {err}"),
        })
}

#[cfg(feature = "images")]
fn premultiply_rgba(pixels: &mut [u8]) {
    for pixel in pixels.chunks_exact_mut(4) {
        let alpha = pixel[3] as u16;
        pixel[0] = ((pixel[0] as u16 * alpha + 127) / 255) as u8;
        pixel[1] = ((pixel[1] as u16 * alpha + 127) / 255) as u8;
        pixel[2] = ((pixel[2] as u16 * alpha + 127) / 255) as u8;
    }
}