1use crate::{Image, ImageAsset};
4
5use image::{AnimationDecoder, ImageDecoder};
6use motion_core::{Duration, FrameRate, Time, ValidationError};
7use std::{
8 io::{Cursor, Read},
9 sync::Arc,
10};
11
12const MAX_FRAMES: usize = 4096;
13
14const MAX_BYTES: usize = 256 * 1024 * 1024;
15
16fn invalid(field: &str, message: &str) -> ValidationError {
17 ValidationError::invalid("animated_image", field, message)
18}
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum ImageLoopMode {
23 #[default]
25 File,
26 Once,
28 Loop,
30 PingPong,
32}
33
34#[derive(Clone, Debug)]
37pub struct AnimatedImageAsset {
38 frames: Arc<[Arc<ImageAsset>]>,
39 ends: Arc<[Time]>,
40 plays: Option<u32>,
41 bytes: usize,
42}
43
44impl AnimatedImageAsset {
45 pub fn from_frames(frames: Vec<(Arc<ImageAsset>, Duration)>) -> Result<Self, ValidationError> {
48 if frames.is_empty() || frames.len() > MAX_FRAMES {
49 return Err(invalid("frames", "Animation requires 1..=4096 frames"));
50 }
51 let size = (frames[0].0.width(), frames[0].0.height());
52 let mut total = 0i64;
53 let mut bytes =
54 frames.len() * (std::mem::size_of::<Arc<ImageAsset>>() + std::mem::size_of::<Time>());
55 let mut seen = std::collections::BTreeSet::new();
56 let mut ends = Vec::with_capacity(frames.len());
57 let mut images = Vec::with_capacity(frames.len());
58 for (frame, duration) in frames {
59 if (frame.width(), frame.height()) != size || duration < Time::ZERO {
60 return Err(invalid(
61 "frames",
62 "Animation dimensions must match and durations must be nonnegative",
63 ));
64 }
65 total = total
66 .checked_add(duration.ticks())
67 .ok_or_else(|| invalid("duration", "Animation time overflow"))?;
68 if total > 86400 * motion_core::TICKS_PER_SECOND {
69 return Err(invalid("duration", "Animation exceeds 24 hours"));
70 }
71 if seen.insert(Arc::as_ptr(&frame) as usize) {
72 bytes = bytes
73 .checked_add(frame.retained_bytes())
74 .ok_or_else(|| invalid("bytes", "Animation storage overflow"))?;
75 }
76 if bytes > MAX_BYTES {
77 return Err(invalid("bytes", "Animation mip data exceeds 256 MiB"));
78 }
79 ends.push(Time::from_ticks(total));
80 images.push(frame);
81 }
82 if total == 0 {
83 return Err(invalid(
84 "duration",
85 "Animation total duration must be positive",
86 ));
87 }
88 Ok(Self {
89 frames: images.into(),
90 ends: ends.into(),
91 plays: Some(1),
92 bytes,
93 })
94 }
95
96 pub fn from_sequence(
98 frames: Vec<Arc<ImageAsset>>,
99 rate: FrameRate,
100 ) -> Result<Self, ValidationError> {
101 if frames.len() > MAX_FRAMES
102 || u64::from(rate.numerator())
103 > motion_core::TICKS_PER_SECOND as u64 * u64::from(rate.denominator())
104 {
105 return Err(invalid("frames", "Invalid sequence count/frame rate"));
106 }
107 let frames = frames
108 .into_iter()
109 .enumerate()
110 .map(|(index, frame)| {
111 let start = Time::checked_from_frame(index as i64, rate)
112 .ok_or_else(|| invalid("duration", "Sequence time overflow"))?;
113 let end = Time::checked_from_frame(index as i64 + 1, rate)
114 .ok_or_else(|| invalid("duration", "Sequence time overflow"))?;
115 Ok((frame, end - start))
116 })
117 .collect::<Result<Vec<_>, ValidationError>>()?;
118 Self::from_frames(frames)
119 }
120
121 pub fn from_bytes(bytes: &[u8]) -> motion_core::Result<Self> {
124 if bytes.len() > 64 * 1024 * 1024 {
125 return Err(invalid("encoded_bytes", "Animation source exceeds 64 MiB").into());
126 }
127 let mut limits = image::Limits::default();
128 limits.max_image_width = Some(16384);
129 limits.max_image_height = Some(16384);
130 limits.max_alloc = Some(MAX_BYTES as u64);
131 let cursor = Cursor::new(bytes);
132 match image::guess_format(bytes).map_err(crate::image_error)? {
133 image::ImageFormat::Gif => {
134 let mut metadata = gif::DecodeOptions::new();
135 metadata.skip_frame_decoding(true);
136 let mut metadata = metadata.read_info(Cursor::new(bytes)).map_err(|error| {
137 motion_core::Error::asset("animated image", "<memory>", error)
138 })?;
139 let mut count = 0;
140 while metadata
141 .read_next_frame()
142 .map_err(|error| {
143 motion_core::Error::asset("animated image", "<memory>", error)
144 })?
145 .is_some()
146 {
147 count += 1;
148 if count > MAX_FRAMES {
149 return Err(invalid("frames", "Animation exceeds 4096 frames").into());
150 }
151 }
152 let plays = match metadata.repeat() {
153 gif::Repeat::Infinite => None,
154 gif::Repeat::Finite(repeats) => Some(u32::from(repeats) + 1),
155 };
156 let mut decoder =
157 image::codecs::gif::GifDecoder::new(cursor).map_err(crate::image_error)?;
158 decoder.set_limits(limits).map_err(crate::image_error)?;
159 Self::check_dimensions(decoder.dimensions())?;
160 Self::decode(decoder.into_frames(), plays)
161 }
162 image::ImageFormat::Png => {
163 let decoder = image::codecs::png::PngDecoder::with_limits(cursor, limits)
164 .map_err(crate::image_error)?;
165 Self::check_dimensions(decoder.dimensions())?;
166 if !decoder.is_apng().map_err(crate::image_error)? {
167 return Err(invalid("format", "Static PNG is not an animated image").into());
168 }
169 let decoder = decoder.apng().map_err(crate::image_error)?;
170 let plays = loop_count(decoder.loop_count());
171 Self::decode(decoder.into_frames(), plays)
172 }
173 _ => Err(invalid("format", "Animated images require GIF or APNG").into()),
174 }
175 }
176
177 fn check_dimensions((width, height): (u32, u32)) -> Result<(), ValidationError> {
178 if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_777_216 {
179 Err(invalid(
180 "size",
181 "Animation canvas exceeds 16 million pixels",
182 ))
183 } else {
184 Ok(())
185 }
186 }
187
188 fn decode(frames: image::Frames<'_>, plays: Option<u32>) -> motion_core::Result<Self> {
189 let mut values = Vec::new();
190 let mut bytes = 0usize;
191 for frame in frames {
192 if values.len() == MAX_FRAMES {
193 return Err(invalid("frames", "Animation exceeds 4096 frames").into());
194 }
195 let frame = frame.map_err(crate::image_error)?;
196 let (numerator, denominator) = frame.delay().numer_denom_ms();
197 let divisor = u128::from(denominator) * 1000;
198 let ticks = (u128::from(numerator) * motion_core::TICKS_PER_SECOND as u128
199 + divisor / 2)
200 / divisor;
201 let duration = Time::from_ticks(
202 i64::try_from(ticks)
203 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?,
204 );
205 let rgba = frame.into_buffer();
206 let estimate = rgba.width() as usize * rgba.height() as usize * 16;
207 if estimate > MAX_BYTES.saturating_sub(bytes) {
208 return Err(invalid("bytes", "Animation decoded mip allowance exhausted").into());
209 }
210 let asset = Arc::new(ImageAsset::from_rgba8(
211 rgba.width(),
212 rgba.height(),
213 rgba.into_raw(),
214 )?);
215 bytes += asset.retained_bytes();
216 values.push((asset, duration));
217 }
218 let mut asset = Self::from_frames(values)?;
219 asset.plays = plays;
220 Ok(asset)
221 }
222
223 pub fn open(path: impl AsRef<std::path::Path>) -> motion_core::Result<Self> {
225 let path = path.as_ref();
226 let metadata = std::fs::metadata(path)?;
227 if !metadata.is_file() || metadata.len() > 64 * 1024 * 1024 {
228 return Err(invalid("source", "Animation must be a regular file <=64 MiB").into());
229 }
230 let mut bytes = Vec::new();
231 std::fs::File::open(path)?
232 .take(64 * 1024 * 1024 + 1)
233 .read_to_end(&mut bytes)?;
234 Self::from_bytes(&bytes).map_err(|error| {
235 motion_core::Error::asset("animated image", path.display().to_string(), error)
236 })
237 }
238
239 pub fn size(&self) -> motion_core::PixelSize {
241 motion_core::PixelSize::new(self.frames[0].width(), self.frames[0].height())
242 }
243
244 pub fn frames(&self) -> &[Arc<ImageAsset>] {
246 &self.frames
247 }
248
249 pub fn duration(&self) -> Duration {
251 *self.ends.last().expect("nonempty frames")
252 }
253
254 pub fn file_plays(&self) -> Option<u32> {
256 self.plays
257 }
258
259 pub fn frame_duration(&self, index: usize) -> Option<Duration> {
261 self.ends.get(index).map(|end| {
262 *end - index
263 .checked_sub(1)
264 .map(|previous| self.ends[previous])
265 .unwrap_or(Time::ZERO)
266 })
267 }
268
269 pub fn frame_index_at(&self, time: Time, mode: ImageLoopMode) -> Option<usize> {
271 if time < Time::ZERO {
272 return None;
273 }
274 let total = self.duration().ticks();
275 let mut local = time.ticks();
276 match mode {
277 ImageLoopMode::Once => {
278 if local >= total {
279 return Some(self.frames.len() - 1);
280 }
281 }
282 ImageLoopMode::File => {
283 if self
284 .plays
285 .is_some_and(|plays| i128::from(local) >= i128::from(total) * i128::from(plays))
286 {
287 return Some(self.frames.len() - 1);
288 }
289 local %= total;
290 }
291 ImageLoopMode::Loop => local %= total,
292 ImageLoopMode::PingPong => {
293 let first = self.ends[0].ticks();
294 let last = self
295 .frame_duration(self.frames.len() - 1)
296 .expect("last frame")
297 .ticks();
298 let interior = (total - first - last).max(0);
299 local %= total + interior;
300 if local >= total {
301 local = total - last - 1 - (local - total);
302 }
303 }
304 }
305 Some(
306 self.ends
307 .partition_point(|end| end.ticks() <= local)
308 .min(self.frames.len() - 1),
309 )
310 }
311
312 pub fn frame_at(&self, time: Time, mode: ImageLoopMode) -> Option<&Arc<ImageAsset>> {
314 self.frame_index_at(time, mode)
315 .map(|index| &self.frames[index])
316 }
317}
318
319fn loop_count(count: image::metadata::LoopCount) -> Option<u32> {
320 match count {
321 image::metadata::LoopCount::Infinite => None,
322 image::metadata::LoopCount::Finite(count) => Some(count.get()),
323 }
324}
325
326impl motion_core::Asset for AnimatedImageAsset {
327 const KIND: &'static str = "animated image";
328
329 fn decode(bytes: &[u8], _: &motion_core::LoadContext) -> motion_core::Result<Self> {
330 Self::from_bytes(bytes)
331 }
332
333 fn retained_bytes(&self) -> usize {
334 self.bytes
335 }
336}
337
338#[derive(Clone, Debug)]
339enum Input {
340 Lazy(motion_core::Handle<AnimatedImageAsset>),
341 Loaded(Arc<AnimatedImageAsset>),
342}
343
344#[derive(Clone, Debug)]
346pub struct AnimatedImage {
347 input: Input,
348 time: Time,
349 mode: ImageLoopMode,
350 size: Option<motion_core::Size>,
351 fit: crate::Fit,
352 radius: f64,
353 sampling: crate::Sampling,
354}
355
356impl AnimatedImage {
357 pub fn new(handle: motion_core::Handle<AnimatedImageAsset>) -> Self {
359 Self::with_input(Input::Lazy(handle))
360 }
361
362 pub fn from_asset(asset: Arc<AnimatedImageAsset>) -> Self {
364 Self::with_input(Input::Loaded(asset))
365 }
366
367 fn with_input(input: Input) -> Self {
368 Self {
369 input,
370 time: Time::ZERO,
371 mode: ImageLoopMode::File,
372 size: None,
373 fit: crate::Fit::Contain,
374 radius: 0.0,
375 sampling: crate::Sampling::Trilinear,
376 }
377 }
378
379 pub fn animation_time(mut self, time: Time) -> Self {
381 self.time = time;
382 self
383 }
384
385 pub fn loop_mode(mut self, mode: ImageLoopMode) -> Self {
387 self.mode = mode;
388 self
389 }
390
391 pub fn size(mut self, width: f64, height: f64) -> Self {
393 self.size = Some(motion_core::Size::new(width, height));
394 self
395 }
396
397 pub fn fit(mut self, fit: crate::Fit) -> Self {
399 self.fit = fit;
400 self
401 }
402
403 pub fn corner_radius(mut self, radius: f64) -> Self {
405 self.radius = radius;
406 self
407 }
408
409 pub fn sampling(mut self, sampling: crate::Sampling) -> Self {
411 self.sampling = sampling;
412 self
413 }
414
415 fn asset(&self) -> motion_core::Result<Arc<AnimatedImageAsset>> {
416 match &self.input {
417 Input::Lazy(handle) => handle.get(),
418 Input::Loaded(asset) => Ok(asset.clone()),
419 }
420 }
421
422 fn picture(&self, image: Arc<ImageAsset>) -> Image {
423 let mut image = Image::from_asset(image)
424 .fit(self.fit)
425 .corner_radius(self.radius)
426 .sampling(self.sampling);
427 if let Some(size) = self.size {
428 image = image.size(size.width, size.height);
429 }
430 image
431 }
432}
433
434impl motion_core::Drawable for AnimatedImage {
435 fn bounds(&self, context: &motion_core::DrawContext) -> motion_core::Rect {
436 if !self.radius.is_finite() || self.radius < 0.0 {
437 return motion_core::Rect::new(f64::NAN, 0.0, 0.0, 0.0);
438 }
439 match self.asset() {
440 Ok(asset) => {
441 motion_core::Drawable::bounds(&self.picture(asset.frames[0].clone()), context)
442 }
443 Err(_) => self
444 .size
445 .unwrap_or(motion_core::Size::new(64.0, 64.0))
446 .to_rect(),
447 }
448 }
449
450 fn draw(
451 &self,
452 context: &motion_core::DrawContext,
453 canvas: &mut motion_core::Canvas<'_>,
454 ) -> Result<(), ValidationError> {
455 match self.asset() {
456 Ok(asset) => match asset.frame_at(self.time, self.mode) {
457 Some(image) => {
458 motion_core::Drawable::draw(&self.picture(image.clone()), context, canvas)
459 }
460 None => Ok(()),
461 },
462 Err(_) => canvas.fill(
463 &motion_core::Drawable::bounds(self, context),
464 motion_core::Color::rgb(255, 0, 255),
465 motion_core::FillRule::NonZero,
466 ),
467 }
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::asset::AssetsExt;
475 use motion_core::{Asset, TimeLiteral};
476 use motion_render::RenderPrepare;
477
478 fn image(color: [u8; 4]) -> Arc<ImageAsset> {
479 Arc::new(ImageAsset::from_rgba8(2, 1, color.repeat(2)).unwrap())
480 }
481
482 #[test]
483 fn variable_duration_sequences_have_exact_boundaries_loops_and_concurrent_seek()
484 -> motion_core::Result<()> {
485 let asset = Arc::new(AnimatedImageAsset::from_frames(vec![
486 (image([255, 0, 0, 255]), 100.milliseconds()),
487 (image([0, 255, 0, 255]), 300.milliseconds()),
488 (image([0, 0, 255, 255]), 200.milliseconds()),
489 ])?);
490 for (time, index) in [
491 (0, 0),
492 (99, 0),
493 (100, 1),
494 (399, 1),
495 (400, 2),
496 (599, 2),
497 (600, 0),
498 (999, 1),
499 ] {
500 assert_eq!(
501 asset.frame_index_at(time.milliseconds(), ImageLoopMode::Loop),
502 Some(index)
503 );
504 }
505 assert_eq!(
506 asset.frame_index_at((-1).milliseconds(), ImageLoopMode::Loop),
507 None
508 );
509 assert_eq!(
510 asset.frame_index_at(600.milliseconds(), ImageLoopMode::Once),
511 Some(2)
512 );
513 assert_eq!(
514 asset.frame_index_at(899.milliseconds(), ImageLoopMode::PingPong),
515 Some(1)
516 );
517 assert_eq!(
518 asset.frame_index_at(900.milliseconds(), ImageLoopMode::PingPong),
519 Some(0)
520 );
521 for _ in 0..3 {
522 let shared = asset.clone();
523 std::thread::spawn(move || {
524 for tick in (0..1000).rev() {
525 assert_eq!(
526 shared.frame_index_at(tick.milliseconds(), ImageLoopMode::Loop),
527 shared.frame_index_at((tick + 1200).milliseconds(), ImageLoopMode::Loop)
528 );
529 }
530 })
531 .join()
532 .unwrap();
533 }
534 let sequence = AnimatedImageAsset::from_sequence(asset.frames.to_vec(), FrameRate::NTSC)?;
535 assert_eq!(sequence.duration(), Time::from_frame(3, FrameRate::NTSC));
536 assert!(AnimatedImageAsset::from_frames(vec![(image([0; 4]), Time::ZERO)]).is_err());
537 Ok(())
538 }
539
540 #[test]
541 fn gif_and_apng_decode_composited_frames_delays_and_lazy_pixels() -> motion_core::Result<()> {
542 let mut gif = Vec::new();
543 {
544 let mut encoder = image::codecs::gif::GifEncoder::new(&mut gif);
545 encoder
546 .set_repeat(image::codecs::gif::Repeat::Finite(2))
547 .map_err(crate::image_error)?;
548 for (color, delay) in [
549 ([255, 0, 0, 255], 100),
550 ([0, 255, 0, 255], 300),
551 ([0, 0, 255, 255], 200),
552 ] {
553 encoder
554 .encode_frame(image::Frame::from_parts(
555 image::RgbaImage::from_pixel(2, 1, image::Rgba(color)),
556 0,
557 0,
558 image::Delay::from_numer_denom_ms(delay, 1),
559 ))
560 .map_err(crate::image_error)?;
561 }
562 }
563 let animation = AnimatedImageAsset::from_bytes(&gif)?;
564 assert_eq!(animation.duration(), 600.milliseconds());
565 assert_eq!(animation.file_plays(), Some(3));
566 let mut once = Vec::new();
567 {
568 let mut encoder = image::codecs::gif::GifEncoder::new(&mut once);
569 encoder
570 .encode_frame(image::Frame::from_parts(
571 image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])),
572 0,
573 0,
574 image::Delay::from_numer_denom_ms(100, 1),
575 ))
576 .map_err(crate::image_error)?;
577 }
578 assert_eq!(AnimatedImageAsset::from_bytes(&once)?.file_plays(), Some(1));
579 let mut apng = Vec::new();
580 {
581 let mut encoder = png::Encoder::new(&mut apng, 2, 1);
582 encoder.set_color(png::ColorType::Rgba);
583 encoder.set_depth(png::BitDepth::Eight);
584 encoder
585 .set_animated(2, 3)
586 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
587 let mut writer = encoder
588 .write_header()
589 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
590 writer
591 .set_frame_delay(1, 10)
592 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
593 writer
594 .write_image_data(&[255, 0, 0, 255, 255, 0, 0, 255])
595 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
596 writer
597 .set_frame_delay(3, 10)
598 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
599 writer
600 .set_blend_op(png::BlendOp::Over)
601 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
602 writer
603 .write_image_data(&[0, 0, 0, 0, 0, 0, 255, 255])
604 .map_err(|error| motion_core::Error::Parameters(Box::new(error)))?;
605 }
606 let decoded = AnimatedImageAsset::from_bytes(&apng)?;
607 assert_eq!(decoded.duration(), 400.milliseconds());
608 assert_eq!(decoded.file_plays(), Some(3));
609 assert!(decoded.retained_bytes() > 0);
610 let directory = tempfile::tempdir()?;
611 let path = directory.path().join("animation.png");
612 std::fs::write(&path, apng)?;
613 let resources = motion_core::Assets::new(directory.path())?;
614 let handle = resources.animated_image("animation.png");
615 let asset = handle.get()?;
616 std::fs::remove_file(path)?;
617 let picture = AnimatedImage::new(handle)
618 .animation_time(100.milliseconds())
619 .sampling(crate::Sampling::Nearest);
620 let list = motion_core::Node::from(picture).compile(
621 motion_core::Size::new(2.0, 1.0),
622 motion_core::PixelSize::new(2, 1),
623 motion_core::ValidationLimits::default(),
624 )?;
625 let frame = list
626 .prepare(
627 motion_core::ValidationLimits::default(),
628 motion_core::RasterLimits::default(),
629 )?
630 .render_linear(motion_core::RasterLimits::default())?;
631 assert_eq!(
632 motion_render::Bitmap::from_linear(&frame, motion_core::RasterLimits::default())?
633 .bytes(),
634 &[255, 0, 0, 255, 0, 0, 255, 255]
635 );
636 assert!(Arc::strong_count(&asset) >= 1);
637 Ok(())
638 }
639
640 #[cfg(feature = "gpu")]
641 #[test]
642 #[ignore = "requires an actual native GPU adapter; no fonts or encoders"]
643 fn animated_outputs_match_cpu_across_loops_and_exact_ticks() -> motion_core::Result<()> {
644 use motion_core::NodeModifiers;
645 let asset = Arc::new(AnimatedImageAsset::from_frames(vec![
646 (image([255, 0, 0, 128]), 100.milliseconds()),
647 (image([0, 255, 0, 255]), 300.milliseconds()),
648 (image([0, 0, 255, 192]), 200.milliseconds()),
649 ])?);
650 let composition = motion_render::Composition::new(24, 16)
651 .frame_rate(10)
652 .frame_count(20);
653 let design = motion_core::Size::new(12.0, 8.0);
654 let mut cpu = composition.clone().frame_renderer(design)?;
655 let mut gpu = composition
656 .renderer(motion_render::Renderer::Gpu)
657 .frame_renderer(design)?;
658 let mut requests = 0;
659 for mode in [
660 ImageLoopMode::Once,
661 ImageLoopMode::File,
662 ImageLoopMode::Loop,
663 ImageLoopMode::PingPong,
664 ] {
665 for format in [
666 motion_render::PixelFormat::SrgbRgba8 {
667 premultiplied: false,
668 },
669 motion_render::PixelFormat::LinearRgba16Float,
670 motion_render::PixelFormat::Yuv420 { bit_depth: 8 },
671 motion_render::PixelFormat::Yuv420 { bit_depth: 10 },
672 ] {
673 for milliseconds in [600, 0, 99, 100, 399, 400, 599, 1800, 100] {
674 let build = |context: &motion_core::SceneContext<'_>| {
675 motion_core::Node::from(
676 AnimatedImage::from_asset(asset.clone())
677 .animation_time(context.time())
678 .loop_mode(mode)
679 .size(10.0, 6.0)
680 .fit(crate::Fit::Fill)
681 .corner_radius(1.0),
682 )
683 .translate(0.75, 0.5)
684 .opacity(0.8)
685 };
686 let background = if matches!(format, motion_render::PixelFormat::Yuv420 { .. })
687 {
688 Some(motion_core::Color::BLACK)
689 } else {
690 None
691 };
692 let expected = cpu.render_output_at(
693 milliseconds.milliseconds(),
694 background,
695 format,
696 build,
697 )?;
698 let actual = gpu.render_output_at(
699 milliseconds.milliseconds(),
700 background,
701 format,
702 build,
703 )?;
704 for (reference, result) in expected.planes().iter().zip(actual.planes()) {
705 if format == motion_render::PixelFormat::LinearRgba16Float {
706 for (reference, result) in reference
707 .bytes()
708 .as_chunks::<2>()
709 .0
710 .iter()
711 .zip(result.bytes().as_chunks::<2>().0)
712 {
713 let reference =
714 half::f16::from_bits(u16::from_le_bytes(*reference)).to_f32();
715 let result =
716 half::f16::from_bits(u16::from_le_bytes(*result)).to_f32();
717 assert!((reference - result).abs() <= 0.001);
718 }
719 } else if format == (motion_render::PixelFormat::Yuv420 { bit_depth: 10 }) {
720 for (reference, result) in reference
721 .bytes()
722 .as_chunks::<2>()
723 .0
724 .iter()
725 .zip(result.bytes().as_chunks::<2>().0)
726 {
727 assert!(
728 u16::from_le_bytes(*reference)
729 .abs_diff(u16::from_le_bytes(*result))
730 <= 1
731 );
732 }
733 } else {
734 let mismatch = reference
735 .bytes()
736 .iter()
737 .zip(result.bytes())
738 .enumerate()
739 .find(|(_, (reference, result))| reference.abs_diff(**result) > 1);
740 assert!(
741 mismatch.is_none(),
742 "mode={mode:?},format={format:?},time={milliseconds},mismatch={mismatch:?}"
743 );
744 }
745 }
746 assert_eq!(
747 actual,
748 gpu.render_output_at(
749 milliseconds.milliseconds(),
750 background,
751 format,
752 build
753 )?
754 );
755 requests += 1;
756 }
757 }
758 }
759 assert_eq!(requests, 144);
760 Ok(())
761 }
762}