Skip to main content

motion_content/
image.rs

1//! Immutable static and animated image resources.
2#[cfg(all(test, feature = "image"))]
3use image::{DynamicImage, ImageFormat};
4use std::io;
5#[cfg(all(test, feature = "image"))]
6use std::{io::Cursor, sync::Arc};
7
8#[cfg(feature = "image")]
9mod animated;
10mod static_image;
11#[cfg(all(test, feature = "svg"))]
12mod svg;
13
14#[cfg(feature = "image")]
15pub use animated::{AnimatedImage, AnimatedImageAsset, ImageLoopMode};
16pub use static_image::{Fit, Image, ImageAsset, ImageLevel, Sampling};
17
18#[cfg(feature = "image")]
19const MAX_ENCODED: usize = 64 * 1024 * 1024;
20
21const MAX_PIXELS: usize = 16_777_216;
22
23fn pixel_count(width: u32, height: u32) -> io::Result<usize> {
24    if width == 0 || height == 0 || width > 16384 || height > 16384 {
25        return Err(invalid("Image dimensions must be in 1..=16384"));
26    }
27    let count = u64::from(width) * u64::from(height);
28    if count > MAX_PIXELS as u64 {
29        return Err(invalid("Image exceeds 16777216 source pixels"));
30    }
31    Ok(count as usize)
32}
33
34fn invalid(message: &str) -> io::Error {
35    io::Error::new(io::ErrorKind::InvalidInput, message)
36}
37
38#[cfg(all(test, feature = "image"))]
39mod tests {
40    use super::*;
41    use crate::asset::AssetsExt;
42    use motion_core::NodeModifiers;
43    use motion_core::{Node, Size};
44    #[cfg(feature = "svg")]
45    use motion_render::RenderPrepare;
46    use motion_render::{BackendKind, Composition};
47
48    #[test]
49    #[cfg(feature = "svg")]
50    fn svg_bytes_decode_once_preserve_alpha_and_share_pixels() -> motion_core::Result {
51        let mut bytes = br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="2">
52            <path d="M0 0H2V2H0Z" fill="#ff0000" opacity="0.5"/>
53        </svg>"##
54            .to_vec();
55        let asset = crate::SvgAsset::from_bytes(&bytes)?;
56        bytes.fill(0);
57        assert_eq!(asset.size(), motion_core::Size::new(4.0, 2.0));
58        let frame = asset
59            .tree
60            .node()
61            .compile(
62                asset.size(),
63                motion_core::PixelSize::new(4, 2),
64                motion_core::ValidationLimits::default(),
65            )?
66            .prepare(
67                motion_core::ValidationLimits::default(),
68                motion_core::RasterLimits::default(),
69            )?
70            .render_linear(motion_core::RasterLimits::default())?;
71        assert_eq!(frame.linear_pixel(0, 0), Some([0.5, 0.0, 0.0, 0.5]));
72        assert_eq!(frame.linear_pixel(2, 0), Some([0.0; 4]));
73        assert!(Arc::ptr_eq(&asset.tree, &asset.clone().tree));
74        Ok(())
75    }
76
77    #[test]
78    fn png_roundtrip_file_independence_and_shared_pixels() -> motion_core::Result {
79        let rgba = vec![255, 0, 0, 255, 0, 255, 0, 0];
80        let mut encoded = Cursor::new(Vec::new());
81        DynamicImage::ImageRgba8(image::RgbaImage::from_raw(2, 1, rgba.clone()).unwrap())
82            .write_to(&mut encoded, ImageFormat::Png)
83            .map_err(crate::image_error)?;
84        let asset = ImageAsset::from_bytes(encoded.get_ref())?;
85        assert_eq!((asset.width(), asset.height()), (2, 1));
86        assert_eq!(asset.levels()[0].pixel(0, 0), Some([1.0, 0.0, 0.0, 1.0]));
87        assert_eq!(asset.levels()[0].pixel(1, 0), Some([0.0; 4]));
88        let cloned = asset.clone();
89        assert!(std::ptr::eq(asset.levels(), cloned.levels()));
90        let returned = std::thread::spawn(move || cloned).join().unwrap();
91        assert!(std::ptr::eq(asset.levels(), returned.levels()));
92        let file = tempfile::NamedTempFile::new()?;
93        std::fs::write(file.path(), encoded.into_inner())?;
94        let loaded = ImageAsset::from_bytes(&std::fs::read(file.path())?)?;
95        drop(file);
96        assert_eq!(loaded, asset);
97        Ok(())
98    }
99
100    #[test]
101    fn jpeg_decodes_to_opaque_rgba() -> motion_core::Result {
102        let mut encoded = Cursor::new(Vec::new());
103        DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([120, 80, 40])))
104            .write_to(&mut encoded, ImageFormat::Jpeg)
105            .map_err(crate::image_error)?;
106        let asset = ImageAsset::from_bytes(encoded.get_ref())?;
107        assert_eq!((asset.width(), asset.height()), (2, 2));
108        for row in 0..2 {
109            for column in 0..2 {
110                let pixel = asset.levels()[0].pixel(column, row).unwrap();
111                assert_eq!(pixel[3], 1.0);
112                assert!((motion_core::linear_to_srgb(pixel[0]) * 255.0 - 120.0).abs() <= 5.0);
113            }
114        }
115        Ok(())
116    }
117
118    #[test]
119    fn invalid_formats_dimensions_and_buffers_are_rejected() -> motion_core::Result {
120        use image::ImageEncoder;
121        for (width, height) in [
122            (0, 1),
123            (1, 0),
124            (16385, 1),
125            (4097, 4097),
126            (u32::MAX, u32::MAX),
127        ] {
128            assert!(ImageAsset::from_rgba8(width, height, Vec::new()).is_err());
129        }
130        assert!(ImageAsset::from_rgba8(1, 1, vec![0; 3]).is_err());
131        assert!(ImageAsset::from_bytes(b"not an image").is_err());
132        let mut encoded = Vec::new();
133        image::codecs::png::PngEncoder::new(&mut encoded)
134            .write_image(
135                &vec![0; 16385 * 4],
136                16385,
137                1,
138                image::ExtendedColorType::Rgba8,
139            )
140            .map_err(crate::image_error)?;
141        assert!(ImageAsset::from_bytes(&encoded).is_err());
142        encoded.truncate(20);
143        assert!(ImageAsset::from_bytes(&encoded).is_err());
144        Ok(())
145    }
146
147    #[test]
148    fn sequence_sampling_shares_pixels_and_supports_remapped_random_access() -> motion_core::Result
149    {
150        let images = (0..4)
151            .map(|index| ImageAsset::from_rgba8(1, 1, vec![index * 60, 0, 0, 255]).map(Arc::new))
152            .collect::<motion_core::Result<Vec<_>>>()?;
153        let rate = motion_core::FrameRate::NTSC;
154        let sequence = AnimatedImageAsset::from_sequence(images.clone(), rate)?;
155        let clone = sequence.clone();
156        assert!(std::ptr::eq(sequence.frames(), clone.frames()));
157        assert_eq!(sequence.size(), motion_core::PixelSize::new(1, 1));
158        assert_eq!(sequence.frames().len(), 4);
159        for index in [3, 0, 2, 1, 19, 3] {
160            let selected = sequence.frame_at(
161                motion_core::Duration::from_frame(index, rate),
162                ImageLoopMode::Once,
163            );
164            assert!(Arc::ptr_eq(selected, &images[index.min(3) as usize]));
165        }
166        let reversed =
167            AnimatedImageAsset::from_sequence(images[1..4].iter().rev().cloned().collect(), rate)?;
168        for (index, expected) in [(0, 3), (1, 3), (2, 2), (4, 1), (19, 1), (0, 3)] {
169            let selected = reversed.frame_at(
170                motion_core::Duration::from_frame(index, rate) / 2.0,
171                ImageLoopMode::Once,
172            );
173            assert!(Arc::ptr_eq(selected, &images[expected]));
174        }
175        let freeze = AnimatedImageAsset::from_sequence(vec![images[2].clone()], rate)?;
176        assert!(Arc::ptr_eq(
177            freeze.frame_at(
178                motion_core::Duration::from_frame(19, rate),
179                ImageLoopMode::Loop
180            ),
181            &images[2]
182        ));
183        let joined = std::thread::spawn(move || clone).join().unwrap();
184        assert!(std::ptr::eq(sequence.frames(), joined.frames()));
185        Ok(())
186    }
187
188    #[test]
189    fn sequence_open_preserves_explicit_order_and_survives_file_removal() -> motion_core::Result {
190        let directory = tempfile::tempdir()?;
191        let png_path = directory.path().join("99.dat");
192        let jpeg_path = directory.path().join("01.dat");
193        let mut encoded = Cursor::new(Vec::new());
194        DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
195            2,
196            2,
197            image::Rgba([255, 0, 0, 128]),
198        ))
199        .write_to(&mut encoded, ImageFormat::Png)
200        .map_err(crate::image_error)?;
201        std::fs::write(&png_path, encoded.into_inner())?;
202        let mut encoded = Cursor::new(Vec::new());
203        DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([0, 180, 0])))
204            .write_to(&mut encoded, ImageFormat::Jpeg)
205            .map_err(crate::image_error)?;
206        std::fs::write(&jpeg_path, encoded.into_inner())?;
207        let assets = motion_core::Assets::new(directory.path())?;
208        let sequence = AnimatedImageAsset::from_sequence(
209            vec![assets.image("99.dat").get()?, assets.image("01.dat").get()?],
210            motion_core::fps!(30),
211        )?;
212        assert!(assets.image("missing").get().is_err());
213        directory.close()?;
214        assert!(
215            (sequence.frames()[0].levels()[0].pixel(0, 0).unwrap()[3] - 128.0 / 255.0).abs()
216                < 0.00025
217        );
218        let jpeg = sequence.frames()[1].levels()[0].pixel(0, 0).unwrap();
219        assert_eq!(jpeg[3], 1.0);
220        assert!((motion_core::linear_to_srgb(jpeg[1]) * 255.0 - 180.0).abs() <= 5.0);
221        Ok(())
222    }
223
224    #[test]
225    fn sequences_preserve_original_trim_rate_and_boundary_tables() -> motion_core::Result {
226        use motion_core::{
227            Easing, Keyframes, SceneCache, SceneContext, Signal, Size, Time, TimeLiteral,
228        };
229        let rate = motion_core::fps!(24);
230        let images = (0..120)
231            .map(|index| ImageAsset::from_rgba8(1, 1, vec![index, 0, 0, 255]).map(Arc::new))
232            .collect::<motion_core::Result<Vec<_>>>()?;
233        let sequence = |indices: Vec<usize>| {
234            AnimatedImageAsset::from_sequence(
235                indices
236                    .into_iter()
237                    .map(|index| images[index].clone())
238                    .collect(),
239                rate,
240            )
241        };
242        for (indices, speed, expected) in [
243            ((20..40).collect(), 1.0, [20, 21, 22, 23, 24, 25, 26]),
244            ((20..40).collect(), 0.5, [20, 20, 21, 21, 22, 22, 23]),
245            ((20..40).collect(), 2.0, [20, 22, 24, 26, 28, 30, 32]),
246            ((20..40).collect(), 1.5, [20, 21, 23, 24, 26, 27, 29]),
247            ((20..40).rev().collect(), 1.0, [39, 38, 37, 36, 35, 34, 33]),
248            (vec![27], 0.0, [27; 7]),
249        ] {
250            let asset = sequence(indices)?;
251            for index in [6, 0, 5, 1, 4, 2, 3, 0] {
252                assert!(Arc::ptr_eq(
253                    asset.frame_at(
254                        motion_core::Duration::from_frame(index, rate) * speed,
255                        ImageLoopMode::Once
256                    ),
257                    &images[expected[index as usize]]
258                ));
259            }
260        }
261        for (indices, mode, speed, expected) in [
262            (
263                (10..14).collect(),
264                ImageLoopMode::Once,
265                1.0,
266                [10, 11, 12, 13, 13, 13, 13, 13, 13],
267            ),
268            (
269                (10..14).collect(),
270                ImageLoopMode::Loop,
271                1.0,
272                [10, 11, 12, 13, 10, 11, 12, 13, 10],
273            ),
274            (
275                (10..14).rev().collect(),
276                ImageLoopMode::Loop,
277                1.0,
278                [13, 12, 11, 10, 13, 12, 11, 10, 13],
279            ),
280            (
281                (10..14).collect(),
282                ImageLoopMode::PingPong,
283                1.0,
284                [10, 11, 12, 13, 12, 11, 10, 11, 12],
285            ),
286            (
287                (10..14).rev().collect(),
288                ImageLoopMode::PingPong,
289                1.0,
290                [13, 12, 11, 10, 11, 12, 13, 12, 11],
291            ),
292            (
293                (10..14).collect(),
294                ImageLoopMode::PingPong,
295                0.5,
296                [10, 10, 11, 11, 12, 12, 13, 13, 12],
297            ),
298            (
299                (10..14).collect(),
300                ImageLoopMode::PingPong,
301                1.5,
302                [10, 11, 13, 12, 10, 11, 13, 12, 10],
303            ),
304            (
305                (10..14).collect(),
306                ImageLoopMode::Loop,
307                2.5,
308                [10, 12, 11, 13, 12, 10, 13, 11, 10],
309            ),
310        ] {
311            let asset = sequence(indices)?;
312            for index in [8, 0, 7, 1, 6, 2, 5, 3, 4, 0] {
313                assert!(Arc::ptr_eq(
314                    asset.frame_at(motion_core::Duration::from_frame(index, rate) * speed, mode),
315                    &images[expected[index as usize]]
316                ));
317            }
318        }
319        let track = Keyframes::new(0.0).to(2.seconds(), 48.0, Easing::Linear);
320        let cache = SceneCache::new(0);
321        let source = SceneContext::new(
322            Time::from_frame(24, rate),
323            rate,
324            Size::new(123.0, 456.0),
325            &cache,
326        )
327        .with_clip_duration(motion_core::Duration::from_frame(120, rate));
328        assert_eq!(source.sample(&track), 24.0);
329        assert_eq!(source.time().as_seconds(), 1.0);
330        assert_eq!(
331            source
332                .shifted(motion_core::Duration::from_frame(20, rate).offset())
333                .time()
334                .to_frame(rate),
335            4
336        );
337        assert_eq!(source.size(), Size::new(123.0, 456.0));
338        assert_eq!(source.frame_rate(), rate);
339        assert_eq!(source.clip_duration(), Some(5.seconds()));
340        assert_eq!(track.sample(source.time()), 24.0);
341        Ok(())
342    }
343
344    #[test]
345    fn sequence_rejects_empty_mixed_and_excessive_assets_without_large_allocations()
346    -> motion_core::Result {
347        let rate = motion_core::fps!(30);
348        assert!(AnimatedImageAsset::from_sequence(Vec::new(), rate).is_err());
349        let small = Arc::new(ImageAsset::from_rgba8(1, 1, vec![0; 4])?);
350        let wide = Arc::new(ImageAsset::from_rgba8(2, 1, vec![0; 8])?);
351        assert!(AnimatedImageAsset::from_sequence(vec![small.clone(), wide], rate).is_err());
352        assert!(AnimatedImageAsset::from_sequence(vec![small.clone(); 4096], rate).is_ok());
353        assert!(AnimatedImageAsset::from_sequence(vec![small.clone(); 4097], rate).is_err());
354        Ok(())
355    }
356
357    #[test]
358    #[ignore = "requires native GPU when enabled; no fonts or window"]
359    fn sequence_rendering_matches_manual_images_after_seeking() -> motion_core::Result {
360        let images = (0..4)
361            .map(|index| {
362                ImageAsset::from_rgba8(1, 1, vec![index * 60, 30, 180, 128])
363                    .map(std::sync::Arc::new)
364            })
365            .collect::<motion_core::Result<Vec<_>>>()?;
366        let sequence = std::sync::Arc::new(AnimatedImageAsset::from_sequence(
367            images[1..4].iter().rev().cloned().collect(),
368            motion_core::fps!(30),
369        )?);
370        for backend in [
371            BackendKind::Cpu,
372            #[cfg(feature = "gpu")]
373            BackendKind::Gpu,
374        ] {
375            let composition = Composition::new(
376                8,
377                8,
378                motion_render::FrameClock::new(motion_core::fps!(30), 20),
379            )
380            .with_options(
381                motion_render::RenderOptions::default()
382                    .backend(motion_render::BackendPreference::from_kind(backend)),
383            )?;
384            let mut actual = composition.frame_renderer(Size::new(8.0, 8.0))?;
385            let mut manual = composition.frame_renderer(Size::new(8.0, 8.0))?;
386            for (mode, expected) in [
387                (ImageLoopMode::Once, [3, 2, 1, 3, 1, 1, 1, 1, 3]),
388                (ImageLoopMode::Loop, [3, 2, 3, 3, 1, 3, 2, 1, 3]),
389                (ImageLoopMode::PingPong, [3, 2, 2, 3, 1, 2, 3, 2, 3]),
390            ] {
391                for (position, index) in [0, 2, 19, 1, 4, 6, 8, 10, 0].into_iter().enumerate() {
392                    let sampled = actual.render_frame(index, |context| {
393                        Node::from(
394                            AnimatedImage::from_asset(sequence.clone())
395                                .animation_time(context.elapsed() / 2.0)
396                                .loop_mode(mode)
397                                .size(4.0, 4.0),
398                        )
399                        .translate(2.0, 2.0)
400                    })?;
401                    let reference = manual.render_frame(index, |_| {
402                        Node::from(
403                            Image::from_asset(images[expected[position]].clone()).size(4.0, 4.0),
404                        )
405                        .translate(2.0, 2.0)
406                    })?;
407                    assert!(
408                        sampled.packed_rgb() == reference.packed_rgb(),
409                        "sequence mismatch at {index} in {mode:?}"
410                    );
411                    assert_ne!(sampled.packed_rgb()[3 * 8 + 3], 0x00ff_ffff);
412                    assert_eq!(sampled.packed_rgb()[0], 0x00ff_ffff);
413                }
414            }
415        }
416        Ok(())
417    }
418}