Skip to main content

motion_content/
asset.rs

1//! Typed resource access through the unified lazy owner.
2
3use crate::ImageAsset;
4#[cfg(feature = "text")]
5use motion_core::LoadContext;
6use motion_core::{Asset, Assets, Handle};
7#[cfg(test)]
8use std::sync::Arc;
9
10/// Typed loading through shared lazy handles.
11pub trait AssetsExt {
12    /// Loads one typed resource without reading or decoding it.
13    fn asset<A: Asset>(&self, path: &str) -> Handle<A>;
14
15    /// Loads or resolves an immutable image.
16    fn image(&self, path: &str) -> Handle<ImageAsset> {
17        self.asset(path)
18    }
19
20    /// Loads or resolves immutable SVG content.
21    #[cfg(feature = "svg")]
22    fn svg(&self, path: &str) -> Handle<crate::SvgAsset> {
23        self.asset(path)
24    }
25
26    /// Loads or resolves an immutable animation.
27    #[cfg(feature = "image")]
28    fn animated_image(&self, path: &str) -> Handle<crate::AnimatedImageAsset> {
29        self.asset(path)
30    }
31
32    /// Loads or resolves one explicit font face.
33    #[cfg(feature = "text")]
34    fn font(&self, path: &str) -> Handle<crate::FontFace> {
35        self.asset(path)
36    }
37}
38
39impl AssetsExt for Assets {
40    fn asset<A: Asset>(&self, path: &str) -> Handle<A> {
41        self.load(path)
42    }
43}
44
45#[cfg(feature = "text")]
46impl Asset for crate::FontFace {
47    const KIND: &'static str = "font";
48
49    fn decode(bytes: &[u8], _: &LoadContext) -> motion_core::Result<Self> {
50        Ok(Self::from_bytes(bytes.to_vec(), 0)?)
51    }
52
53    fn retained_bytes(&self) -> usize {
54        self.byte_len()
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[cfg(feature = "svg")]
63    #[test]
64    fn source_drawables_use_the_same_owner_as_the_scene_callback() -> motion_core::Result {
65        use motion_core::{Node, NodeModifiers, Size, Source};
66        use motion_render::{Composition, RenderContext};
67        let assets = Assets::new(".")?;
68        assets.preload(
69            "logo.png",
70            Arc::new(ImageAsset::from_rgba8(2, 2, vec![255; 16])?),
71        )?;
72        let svg = assets.load::<crate::SvgAsset>(Source::Bytes {
73            key: "parsed-svg".into(),
74            data: Arc::from(&br##"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2" fill="#00ff00"/></svg>"##[..]),
75        }).get()?;
76        assets.preload("diagram.svg", svg)?;
77        let composition =
78            Composition::new(8, 4).render_context(RenderContext::new().with_assets(assets.clone()));
79        let frame = composition.render_frame(0, Size::new(8.0, 4.0), |context| {
80            assert!(context.assets().pool().same_pool(assets.pool()));
81            for node in [
82                Node::from(crate::Image::open("logo.png")),
83                Node::from(crate::Svg::open("diagram.svg")),
84            ] {
85                assert_eq!(
86                    context.measure(&node).unwrap(),
87                    motion_core::Rect::new(0.0, 0.0, 2.0, 2.0)
88                );
89            }
90            Node::group((
91                crate::Image::open("logo.png"),
92                crate::Svg::open("diagram.svg").translate(4.0, 0.0),
93            ))
94        })?;
95        assert_eq!(frame.pixels()[0], 0xffffff);
96        assert_eq!(frame.pixels()[4], 0x00ff00);
97        let override_assets = Assets::new(".")?;
98        override_assets.preload(
99            "logo.png",
100            Arc::new(ImageAsset::from_rgba8(1, 1, vec![255, 0, 0, 255])?),
101        )?;
102        let frame = composition
103            .resources(override_assets.clone())
104            .render_frame(0, Size::new(8.0, 4.0), |context| {
105                assert!(context.assets().pool().same_pool(override_assets.pool()));
106                crate::Image::open("logo.png").into()
107            })?;
108        assert_eq!(frame.pixels()[0], 0xff0000);
109        Ok(())
110    }
111
112    #[test]
113    fn source_image_missing_files_follow_strict_and_placeholder_policies() -> motion_core::Result {
114        use motion_core::Size;
115        use motion_render::Composition;
116        let directory = tempfile::tempdir()?;
117        let assets = Assets::new(directory.path())?;
118        let composition = Composition::new(2, 2).resources(assets);
119        let build = |_: &motion_core::SceneContext<'_>| {
120            crate::Image::open("absent.png").size(2.0, 2.0).into()
121        };
122        let error = composition
123            .render_frame(0, Size::new(2.0, 2.0), build)
124            .err()
125            .unwrap();
126        assert_eq!(error.kind(), motion_core::ErrorKind::Asset);
127        assert_eq!(error.frame(), Some(0));
128        let frame =
129            composition
130                .allow_missing_assets(true)
131                .render_frame(0, Size::new(2.0, 2.0), build)?;
132        assert_eq!(frame.pixels()[0], 0xff00ff);
133        Ok(())
134    }
135
136    #[cfg(feature = "text")]
137    #[test]
138    fn invalid_font_does_not_change_resources() {
139        let assets = Assets::default();
140        assert!(crate::FontFace::from_bytes(Vec::new(), 0).is_err());
141        assert_eq!(assets.pool().retained_bytes(), 0);
142    }
143
144    #[test]
145    fn identifier_limit_bounds_tiny_shared_assets() -> motion_core::Result {
146        let pixel = Arc::new(ImageAsset::from_rgba8(1, 1, vec![255; 4])?);
147        let bytes = pixel.retained_bytes();
148        let assets = Assets::new(".")?;
149        for index in 0..4096 {
150            assets.preload(format!("pixel-{index}"), pixel.clone())?;
151        }
152        assert!(assets.preload("overflow", pixel).is_err());
153        assert_eq!(assets.pool().retained_bytes(), 4096 * bytes);
154        Ok(())
155    }
156
157    #[test]
158    fn preloads_respect_allowance_and_snapshot_lifetimes() -> motion_core::Result {
159        let image = Arc::new(ImageAsset::from_rgba8(2, 2, vec![255; 16])?);
160        let bytes = image.retained_bytes();
161        let pool = motion_core::ResourcePool::with_limits(motion_core::CacheLimits {
162            entries: 4096,
163            bytes,
164        })?;
165        let assets = Assets::new(".")?.with_pool(pool.clone());
166        assets.preload("sun", image.clone())?;
167        assert_eq!(pool.retained_bytes(), bytes);
168        assert!(assets.preload("sun", image.clone()).is_err());
169        assert!(assets.preload("moon", image.clone()).is_err());
170        assert!(Arc::ptr_eq(&image, &assets.image("sun").get()?));
171        let snapshot = assets.snapshot();
172        assert!(assets.remove::<ImageAsset>("sun"));
173        assert_eq!(pool.retained_bytes(), bytes);
174        assert!(Arc::ptr_eq(&image, &snapshot.image("sun").get()?));
175        drop(snapshot);
176        assert_eq!(pool.retained_bytes(), 0);
177        assert!(assets.remove::<ImageAsset>("moon"));
178        assets.preload("moon", image)?;
179        Ok(())
180    }
181}