1#[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}