Skip to main content

motion_content/image/
animated.rs

1//! Immutable animations with independent integer-tick frame lookup.
2
3use 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/// Time mapping applied to a decoded animation, without modifying its frame data.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum ImageLoopMode {
23    /// Honor encoded play count; hold the last frame afterwards.
24    #[default]
25    File,
26    /// Play once and hold the final frame.
27    Once,
28    /// Repeat indefinitely.
29    Loop,
30    /// Reverse interior frames without duplicating endpoint durations.
31    PingPong,
32}
33
34/// Fully composed immutable GIF/APNG frames and half-open display intervals.
35/// Up to 4096 frames, 24 hours and 256 MiB mip data; no seek cursor.
36#[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    /// Freezes explicitly timed equal-sized images. Zero durations are instantaneous
46    /// and never selected inside the cycle; total duration must be positive.
47    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    /// Creates a uniform sequence using independent rational frame boundaries.
94    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    /// Decodes GIF/APNG using library disposal/blend rules. Delays round once to
119    /// nearest ticks; no browser-specific minimum display delay is invented.
120    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    /// Explicit bounded local-file loading; rendering never reopens the source.
221    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    /// Dimensions shared by every composed frame.
237    pub fn size(&self) -> motion_core::PixelSize {
238        motion_core::PixelSize::new(self.frames[0].width(), self.frames[0].height())
239    }
240
241    /// Immutable original composed frames.
242    pub fn frames(&self) -> &[Arc<ImageAsset>] {
243        &self.frames
244    }
245
246    /// One cycle's actual rounded duration, not a video-frame count.
247    pub fn duration(&self) -> Duration {
248        *self.ends.last().expect("nonempty frames")
249    }
250
251    /// File-declared play count; None means indefinite repetition.
252    pub fn file_plays(&self) -> Option<u32> {
253        self.plays
254    }
255
256    /// Original individual frame duration in ticks.
257    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    /// Selects the frame at an elapsed time; finite play holds the last frame afterwards.
267    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    /// Samples without I/O, allocation, decoding or state mutation.
305    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/// animated image using the static image fit/mip/paint pipeline.
336#[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    /// Captures a lazy resource without decoding.
349    pub fn new(handle: motion_core::Handle<AnimatedImageAsset>) -> Self {
350        Self::with_input(Input::Lazy(handle))
351    }
352
353    /// Captures immutable already-decoded sequence data.
354    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    /// Sets source query time independently of output frame rate.
371    pub fn animation_time(mut self, time: Duration) -> Self {
372        self.time = time;
373        self
374    }
375
376    /// Overrides encoded looping metadata.
377    pub fn loop_mode(mut self, mode: ImageLoopMode) -> Self {
378        self.mode = mode;
379        self
380    }
381
382    /// Sets the stable local viewport.
383    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    /// Selects centered fitting.
389    pub fn fit(mut self, fit: crate::Fit) -> Self {
390        self.fit = fit;
391        self
392    }
393
394    /// Selects rounded viewport clipping.
395    pub fn corner_radius(mut self, radius: f64) -> Self {
396        self.radius = radius;
397        self
398    }
399
400    /// Selects shared texture reconstruction.
401    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}