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, 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/// 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<[Time]>,
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 = 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    /// Creates a uniform sequence using independent rational frame boundaries.
97    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    /// Decodes GIF/APNG using library disposal/blend rules. Delays round once to
122    /// nearest ticks; no browser-specific minimum display delay is invented.
123    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    /// Explicit bounded local-file loading; rendering never reopens the source.
224    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    /// Dimensions shared by every composed frame.
240    pub fn size(&self) -> motion_core::PixelSize {
241        motion_core::PixelSize::new(self.frames[0].width(), self.frames[0].height())
242    }
243
244    /// Immutable original composed frames.
245    pub fn frames(&self) -> &[Arc<ImageAsset>] {
246        &self.frames
247    }
248
249    /// One cycle's actual rounded duration, not a video-frame count.
250    pub fn duration(&self) -> Duration {
251        *self.ends.last().expect("nonempty frames")
252    }
253
254    /// File-declared play count; None means indefinite repetition.
255    pub fn file_plays(&self) -> Option<u32> {
256        self.plays
257    }
258
259    /// Original individual frame duration in ticks.
260    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    /// Negative time is absent; finite play holds the last frame afterwards.
270    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    /// Samples without I/O, allocation, decoding or state mutation.
313    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/// animated image using the static image fit/mip/paint pipeline.
345#[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    /// Captures a lazy resource without decoding.
358    pub fn new(handle: motion_core::Handle<AnimatedImageAsset>) -> Self {
359        Self::with_input(Input::Lazy(handle))
360    }
361
362    /// Captures immutable already-decoded sequence data.
363    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    /// Sets source query time independently of output frame rate.
380    pub fn animation_time(mut self, time: Time) -> Self {
381        self.time = time;
382        self
383    }
384
385    /// Overrides encoded looping metadata.
386    pub fn loop_mode(mut self, mode: ImageLoopMode) -> Self {
387        self.mode = mode;
388        self
389    }
390
391    /// Sets the stable local viewport.
392    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    /// Selects centered fitting.
398    pub fn fit(mut self, fit: crate::Fit) -> Self {
399        self.fit = fit;
400        self
401    }
402
403    /// Selects rounded viewport clipping.
404    pub fn corner_radius(mut self, radius: f64) -> Self {
405        self.radius = radius;
406        self
407    }
408
409    /// Selects shared texture reconstruction.
410    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}