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