motion-content 0.1.0

Backend-independent images, text, vectors, particles and artwork
Documentation
use super::*;
use crate::asset::AssetsExt;
use motion_core::{Asset, NodeModifiers};
use motion_render::RenderPrepare;

fn pixels(asset: &SvgAsset) -> motion_core::Result<motion_render::LinearFrame> {
    Ok(asset
        .tree
        .node()
        .compile(
            asset.size(),
            motion_core::PixelSize::new(
                asset.size().width.ceil() as u32,
                asset.size().height.ceil() as u32,
            ),
            motion_core::ValidationLimits::default(),
        )?
        .prepare(
            motion_core::ValidationLimits::default(),
            motion_core::RasterLimits::default(),
        )?
        .render_linear(motion_core::RasterLimits::default())?)
}

#[test]
fn svg_paths_ids_viewbox_and_transforms_enter_vectors() -> motion_core::Result<()> {
    let asset=SvgAsset::from_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4" viewBox="10 20 8 4"><g id="group" transform="translate(10 20)"><path id="blue" fill="blue" d="M0 0h8v4h-8z"/></g></svg>"#)?;
    assert_eq!(asset.view_box, Rect::new(10.0, 20.0, 18.0, 24.0));
    assert!(asset.tree.get("blue").is_some());
    let frame = asset
        .tree
        .node()
        .compile(
            asset.size(),
            motion_core::PixelSize::new(8, 4),
            motion_core::ValidationLimits::default(),
        )?
        .prepare(
            motion_core::ValidationLimits::default(),
            motion_core::RasterLimits::default(),
        )?
        .render_linear(motion_core::RasterLimits::default())?;
    assert_eq!(frame.pixel(2, 2), Some([0.0, 0.0, 1.0, 1.0]));
    assert!(SvgAsset::from_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg"><image href="file:///tmp/other.png"/></svg>"#).is_err());
    Ok(())
}

#[test]
fn svg_gradients_strokes_clips_masks_filters_and_diagnostics_are() -> motion_core::Result<()> {
    let asset=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="8"><defs><linearGradient id="g"><stop stop-color="red"/><stop offset="1" stop-color="blue"/></linearGradient><clipPath id="c"><rect width="14" height="8"/></clipPath><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="8"><rect width="8" height="8" fill="white"/></mask></defs><g clip-path="url(#c)" mask="url(#m)"><rect id="art" width="16" height="8" fill="url(#g)" stroke="green" stroke-width="1" paint-order="stroke fill"/></g></svg>"##)?;
    let frame = pixels(&asset)?;
    assert!(frame.pixel(1, 3).unwrap()[0] > frame.pixel(1, 3).unwrap()[2]);
    assert_eq!(frame.pixel(10, 3), Some([0.0; 4]));
    assert!(asset.tree.get("art").is_some());
    assert!(asset.diagnostics().is_empty());
    let blur=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><defs><filter id="b" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="1"/></filter></defs><rect x="4" y="4" width="4" height="4" fill="red" filter="url(#b)"/></svg>"##)?;
    assert!(blur.diagnostics().is_empty());
    let frame = pixels(&blur)?;
    assert!(frame.pixel(5, 3).unwrap()[3] > 0.0);
    assert!(frame.pixel(5, 5).unwrap()[3] > frame.pixel(5, 3).unwrap()[3]);
    let matrix=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><defs><filter id="f"><feColorMatrix values="0 0 1 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 1 0"/></filter></defs><rect width="4" height="4" fill="red" filter="url(#f)"/></svg>"##)?;
    assert_eq!(pixels(&matrix)?.pixel(2, 2), Some([0.0, 0.0, 1.0, 1.0]));
    let unsupported=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><defs><filter id="f"><feTurbulence/></filter></defs><rect width="4" height="4" fill="red" filter="url(#f)"/></svg>"##)?;
    assert_eq!(unsupported.diagnostics().len(), 1);
    assert_eq!(
        pixels(&unsupported)?.pixel(2, 2),
        Some([1.0, 0.0, 0.0, 1.0])
    );
    let clip=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="4"><defs><clipPath id="c"><rect width="4" height="4"/><rect x="8" width="4" height="4" visibility="hidden"/></clipPath></defs><rect width="12" height="4" fill="red" clip-path="url(#c)"/></svg>"##)?;
    assert_eq!(pixels(&clip)?.pixel(2, 2), Some([1.0, 0.0, 0.0, 1.0]));
    assert_eq!(pixels(&clip)?.pixel(10, 2), Some([0.0; 4]));
    Ok(())
}

#[test]
fn svg_lazy_selection_writing_images_and_security_keep_original_resources()
-> motion_core::Result<()> {
    let missing = motion_core::Assets::new(".")?;
    let captured = Svg::new(missing.svg("missing-native-svg-diagnostic.svg")).translate(1.0, 1.0);
    let composition = motion_render::Composition::new(8, 8);
    let error = composition
        .render_frame(0, Size::new(8.0, 8.0), |_| captured.clone())
        .err()
        .expect("strict SVG resource failure");
    assert_eq!(error.kind(), motion_core::ErrorKind::Asset);
    assert_eq!(error.frame(), Some(0));
    let placeholder =
        composition
            .allow_missing_assets(true)
            .render_frame(0, Size::new(8.0, 8.0), |_| captured.clone())?;
    assert!(placeholder.pixels().contains(&0xff00ff));
    let directory = tempfile::tempdir()?;
    let path = directory.path().join("art.svg");
    std::fs::write(&path,br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4"><rect id="art" width="8" height="4" fill="blue"/></svg>"#)?;
    let assets = motion_core::Assets::new(directory.path())?;
    let handle = assets.svg("art.svg");
    let asset = handle.get()?;
    std::fs::remove_file(path)?;
    let view = Svg::new(handle)
        .fit(Size::new(8.0, 8.0))
        .select("#art", |node| node.opacity(0.5))
        .write(1.0);
    let node = view.try_node()?;
    let frame = node
        .compile(
            Size::new(8.0, 8.0),
            motion_core::PixelSize::new(8, 8),
            motion_core::ValidationLimits::default(),
        )?
        .prepare(
            motion_core::ValidationLimits::default(),
            motion_core::RasterLimits::default(),
        )?
        .render_linear(motion_core::RasterLimits::default())?;
    assert_eq!(frame.pixel(2, 0), Some([0.0; 4]));
    assert_eq!(frame.pixel(2, 3), Some([0.0, 0.0, 0.5, 0.5]));
    assert!(
        Svg::from_asset(asset)
            .select("missing", |node| node)
            .try_node()
            .is_err()
    );
    let mut bytes = Vec::new();

    use image::ImageEncoder;
    image::codecs::png::PngEncoder::new(&mut bytes)
        .write_image(&[255, 0, 0, 128], 1, 1, image::ExtendedColorType::Rgba8)
        .map_err(crate::image_error)?;
    let escaped = bytes
        .iter()
        .map(|byte| format!("%{byte:02X}"))
        .collect::<String>();
    let source = format!(
        "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><image width='1' height='1' href='data:image/png,{escaped}'/></svg>"
    );
    let image = SvgAsset::from_bytes(source.as_bytes())?;
    assert!(image.retained_bytes() > 0);
    assert_eq!(
        motion_render::Bitmap::from_linear(&pixels(&image)?, motion_core::RasterLimits::default())?
            .bytes(),
        [255, 0, 0, 128]
    );
    for body in [
        "<script/>",
        "<image href='file:///tmp/file.png'/>",
        "<foreignObject/>",
        "<animate/>",
        "<image href='data:image/png,bad'/>",
    ] {
        let source =
            format!("<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'>{body}</svg>");
        assert!(SvgAsset::from_bytes(source.as_bytes()).is_err(), "{body}");
    }
    assert!(SvgAsset::from_bytes(b"<!DOCTYPE svg [<!ENTITY x '1'>]><svg xmlns='http://www.w3.org/2000/svg' width='&x;' height='1'/>").is_err());
    Ok(())
}

#[test]
fn nested_svg_retains_vector_content_without_raster_fallback() -> motion_core::Result<()> {
    let nested = "<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2'><rect width='2' height='2' fill='lime'/></svg>";
    let escaped = nested
        .as_bytes()
        .iter()
        .map(|byte| format!("%{byte:02X}"))
        .collect::<String>();
    let source = format!(
        "<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2'><image width='2' height='2' href='data:image/svg+xml,{escaped}'/></svg>"
    );
    let asset = SvgAsset::from_bytes(source.as_bytes())?;
    assert!(
        asset
            .tree
            .paint_leaves()
            .iter()
            .all(|leaf| matches!(leaf.item, VectorItem::Path(_)))
    );
    assert_eq!(pixels(&asset)?.pixel(0, 0), Some([0.0, 1.0, 0.0, 1.0]));
    Ok(())
}

#[test]
fn svg_isolation_nested_ids_and_alpha_filter_inputs_preserve_semantics() -> motion_core::Result<()>
{
    let named=Arc::new(SvgAsset::from_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg" id="canvas" width="4" height="4"><g id="gear"><rect width="4" height="4" fill="blue"/></g></svg>"#)?);
    assert!(named.tree.ids().contains_key("canvas"));
    assert!(named.tree.get("gear").is_some());
    let selected = Svg::from_asset(named)
        .select("#canvas", |node| node.opacity(0.5))
        .try_node()?
        .compile(
            Size::new(4.0, 4.0),
            motion_core::PixelSize::new(4, 4),
            motion_core::ValidationLimits::default(),
        )?
        .prepare(
            motion_core::ValidationLimits::default(),
            motion_core::RasterLimits::default(),
        )?
        .render_linear(motion_core::RasterLimits::default())?;
    assert_eq!(selected.pixel(1, 1), Some([0.0, 0.0, 0.5, 0.5]));
    let isolated=SvgAsset::from_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="blue"/><g style="isolation:isolate"><rect width="4" height="4" fill="red" style="mix-blend-mode:multiply"/></g></svg>"#)?;
    assert_eq!(pixels(&isolated)?.pixel(1, 1), Some([1.0, 0.0, 0.0, 1.0]));
    let alpha=SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><defs><filter id="a"><feGaussianBlur in="SourceAlpha" stdDeviation="0"/></filter></defs><rect width="4" height="4" fill="red" opacity=".5" filter="url(#a)"/></svg>"##)?;
    assert!(alpha.diagnostics().is_empty());
    let pixel = pixels(&alpha)?.pixel(2, 2).unwrap();
    assert_eq!(&pixel[..3], &[0.0; 3]);
    assert!((pixel[3] - 0.5).abs() < 0.01);
    let nested = "<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2'><rect id='inside' width='2' height='2' fill='lime'/></svg>";
    let escaped = nested
        .as_bytes()
        .iter()
        .map(|byte| format!("%{byte:02X}"))
        .collect::<String>();
    let source = format!(
        "<svg xmlns='http://www.w3.org/2000/svg' width='4' height='2'><image id='left' width='2' height='2' href='data:image/svg+xml,{escaped}'/><image id='right' x='2' width='2' height='2' href='data:image/svg+xml,{escaped}'/></svg>"
    );
    let asset = SvgAsset::from_bytes(source.as_bytes())?;
    assert!(asset.tree.get("left::inside").is_some());
    assert!(asset.tree.get("right::inside").is_some());
    Ok(())
}

#[test]
#[ignore = "requires explicit MOTION_TEST_FONT; no system discovery"]
fn svg_text_uses_shared_fonts_and_outlined_geometry() -> motion_core::Result<()> {
    let path = std::env::var_os("MOTION_TEST_FONT")
        .ok_or_else(|| std::io::Error::other("Set MOTION_TEST_FONT"))?;
    let mut fonts = crate::FontLibrary::new();
    fonts.load_file(path)?;
    let source=br#"<svg xmlns="http://www.w3.org/2000/svg" width="96" height="32"><text id="label" x="4" y="23" font-family="Arial" font-size="20" fill="blue">Hello</text></svg>"#;
    assert!(SvgAsset::from_bytes(source).is_err());
    let asset = SvgAsset::from_bytes_with_fonts(source, &fonts)?;
    assert!(asset.tree.get("label").is_some());
    assert!(pixels(&asset)?.pixels().iter().any(|pixel| pixel[3] > 0.5));
    assert!(
        asset
            .tree
            .paint_leaves()
            .iter()
            .all(|leaf| matches!(leaf.item, VectorItem::Path(_)))
    );
    Ok(())
}

#[cfg(feature = "gpu")]
#[test]
#[ignore = "requires real native GPU; no fonts, window or encoder"]
fn svg_effects_operations_and_focal_paints_match_cpu_gpu() -> motion_core::Result<()> {
    let scenes = [
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"><defs><radialGradient id="g" fx="25%" fy="40%" gradientTransform="skewX(10)"><stop stop-color="yellow"/><stop offset="1" stop-color="blue"/></radialGradient></defs><rect id="art" x="2" y="2" width="20" height="12" fill="url(#g)"/></svg>"##,
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"><defs><clipPath id="c"><circle cx="10" cy="8" r="7"/></clipPath><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="16" mask-type="alpha"><rect width="18" height="16" fill="white" opacity=".6"/></mask></defs><g id="art" clip-path="url(#c)" mask="url(#m)" opacity=".7"><rect width="24" height="16" fill="red"/><circle cx="12" cy="9" r="6" fill="blue"/></g></svg>"##,
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"><defs><filter id="f" x="-100%" y="-100%" width="300%" height="300%"><feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="green"/><feColorMatrix type="saturate" values=".5"/></filter></defs><path id="art" d="M4 3L20 3L12 12Z" fill="red" filter="url(#f)"/></svg>"##,
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"><path id="art" d="M2 4Q12 18 22 4" fill="none" stroke="blue" stroke-width="2" stroke-linecap="round" stroke-dasharray="3 2"/></svg>"##,
    ];
    let limits = motion_core::RasterLimits::default();
    let mut gpu = motion_render::gpu::Gpu::new()?;
    let mut cases = 0;
    let mut maximum = 0u8;
    for source in scenes {
        let asset = Arc::new(SvgAsset::from_bytes(source.as_bytes())?);
        assert!(asset.diagnostics().is_empty(), "{:?}", asset.diagnostics());
        for progress in [0.0, 0.3, 0.85, 1.0, 0.3] {
            for space in [
                motion_core::BlendSpace::LinearRgb,
                motion_core::BlendSpace::Srgb,
            ] {
                let node = Svg::from_asset(asset.clone())
                    .select("art", |node| node.rotate(0.1).opacity(0.8))
                    .write(progress)
                    .fit(Size::new(28.0, 20.0))
                    .try_node()?;
                let prepared = node
                    .compile(
                        Size::new(28.0, 20.0),
                        motion_core::PixelSize::new(56, 40),
                        motion_core::ValidationLimits::default(),
                    )?
                    .prepare(motion_core::ValidationLimits::default(), limits)?
                    .blend_space(space);
                let cpu = prepared.render_linear(limits)?;
                let actual =
                    gpu.render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?;
                assert_eq!(
                    actual.pixels(),
                    gpu.render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
                        .pixels()
                );
                let cpu = cpu.to_srgb_rgba8(false, limits)?;
                let actual = actual.to_srgb_rgba8(false, limits)?;
                for (cpu, actual) in cpu.bytes().iter().zip(actual.bytes()) {
                    maximum = maximum.max(cpu.abs_diff(*actual));
                }
                assert!(maximum <= 2, "SVG case={cases},max_byte={maximum}");
                cases += 1;
            }
        }
    }
    assert_eq!(cases, 40);
    println!("SVG cases={cases} max_byte={maximum} GPU replay=exact");
    Ok(())
}