Skip to main content

motion_content/image/
static_image.rs

1//! Half-float static image assets and resource-backed drawables.
2//!
3//! ```
4//! use motion_content::prelude::*;
5//! use motion_core::prelude::*;
6//! use motion_core::{
7//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
8//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
9//! };
10//! use motion_render::{Composition, RenderPrepare, RenderOptions, Renderer, render_still};
11//! use std::sync::Arc;
12//! let asset = Arc::new(ImageAsset::from_rgba8(
13//!     2,
14//!     1,
15//!     vec![255, 0, 0, 255, 0, 255, 0, 0],
16//! )?);
17//! let picture = Image::from_asset(asset)
18//!     .size(8.0, 4.0)
19//!     .fit(Fit::Cover)
20//!     .corner_radius(1.0)
21//!     .sampling(Sampling::Trilinear);
22//! let frame =
23//!     Composition::new(8, 4).render_frame(0, Size::new(8.0, 4.0), |_| picture.clone().into())?;
24//! assert_eq!(frame.width(), 8);
25//! # Ok::<(), motion_core::Error>(())
26//! ```
27
28#[cfg(feature = "image")]
29use std::io::Cursor;
30use std::{io, sync::Arc};
31
32use half::f16;
33#[cfg(feature = "image")]
34use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
35
36pub use motion_core::pixels::Sampling;
37
38/// Centered image placement in a local viewport.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum Fit {
41    /// Stretch independently along each axis.
42    Fill,
43    /// Preserve aspect ratio with transparent letterboxing.
44    #[default]
45    Contain,
46    /// Preserve aspect ratio and crop to fill the viewport.
47    Cover,
48    /// Keep source pixel dimensions and crop centrally.
49    None,
50}
51
52#[derive(Clone, Debug)]
53enum Input {
54    Source(motion_core::Source),
55    Lazy(motion_core::Handle<ImageAsset>),
56    Loaded(Arc<ImageAsset>),
57}
58
59/// A static image drawable with explicit viewport/fit/corner/sampling.
60/// Missing handles draw a magenta placeholder and report through the native
61/// frame resource diagnostics.
62#[derive(Clone, Debug)]
63pub struct Image {
64    input: Input,
65    size: Option<motion_core::Size>,
66    fit: Fit,
67    radius: f64,
68    sampling: Sampling,
69}
70
71impl Image {
72    /// Captures a path resolved by the session resource owner during compilation.
73    pub fn open(source: impl Into<motion_core::Source>) -> Self {
74        Self {
75            input: Input::Source(source.into()),
76            size: None,
77            fit: Fit::Contain,
78            radius: 0.0,
79            sampling: Sampling::Trilinear,
80        }
81    }
82
83    /// Captures a lazy resource handle without reading or decoding it.
84    pub fn new(handle: motion_core::Handle<ImageAsset>) -> Self {
85        Self {
86            input: Input::Lazy(handle),
87            size: None,
88            fit: Fit::Contain,
89            radius: 0.0,
90            sampling: Sampling::Trilinear,
91        }
92    }
93
94    /// Captures already decoded image data without copying mip pixels.
95    pub fn from_asset(image: Arc<ImageAsset>) -> Self {
96        Self {
97            input: Input::Loaded(image),
98            size: None,
99            fit: Fit::Contain,
100            radius: 0.0,
101            sampling: Sampling::Trilinear,
102        }
103    }
104
105    /// Sets finite nonnegative local viewport dimensions, independent of source size.
106    pub fn size(mut self, width: f64, height: f64) -> Self {
107        self.size = Some(motion_core::Size::new(width, height));
108        self
109    }
110
111    /// Selects centered Fill, Contain, Cover or original-size placement.
112    pub fn fit(mut self, fit: Fit) -> Self {
113        self.fit = fit;
114        self
115    }
116
117    /// Clips to uniformly rounded viewport corners; radius clamps to viewport size.
118    pub fn corner_radius(mut self, radius: f64) -> Self {
119        self.radius = radius;
120        self
121    }
122
123    /// Selects shared nearest, bilinear or transform-derived trilinear sampling.
124    pub fn sampling(mut self, sampling: Sampling) -> Self {
125        self.sampling = sampling;
126        self
127    }
128
129    fn image(&self, context: &motion_core::DrawContext) -> motion_core::Result<Arc<ImageAsset>> {
130        match &self.input {
131            Input::Source(source) => context.assets().load::<ImageAsset>(source.clone()).get(),
132            Input::Lazy(handle) => handle.get(),
133            Input::Loaded(image) => Ok(image.clone()),
134        }
135    }
136
137    fn bounds(&self, context: &motion_core::DrawContext) -> motion_core::Rect {
138        let size = self.size.unwrap_or_else(|| {
139            self.image(context)
140                .map(|image| {
141                    motion_core::Size::new(f64::from(image.width()), f64::from(image.height()))
142                })
143                .unwrap_or(motion_core::Size::new(64.0, 64.0))
144        });
145        if !self.radius.is_finite() || self.radius < 0.0 {
146            return motion_core::Rect::new(f64::NAN, 0.0, 0.0, 0.0);
147        }
148        size.to_rect()
149    }
150}
151
152impl motion_core::Drawable for Image {
153    fn bounds(&self, context: &motion_core::DrawContext) -> motion_core::Rect {
154        self.bounds(context)
155    }
156
157    fn draw(
158        &self,
159        context: &motion_core::DrawContext,
160        canvas: &mut motion_core::Canvas<'_>,
161    ) -> Result<(), motion_core::ValidationError> {
162        let bounds = self.bounds(context);
163        let shape = motion_core::RoundedRect::new(bounds, self.radius);
164        match self.image(context) {
165            Ok(image) => {
166                if bounds.width() == 0.0 || bounds.height() == 0.0 {
167                    return Ok(());
168                }
169                let horizontal = bounds.width() / f64::from(image.width());
170                let vertical = bounds.height() / f64::from(image.height());
171                let (horizontal, vertical) = match self.fit {
172                    Fit::Fill => (horizontal, vertical),
173                    Fit::Contain => (horizontal.min(vertical), horizontal.min(vertical)),
174                    Fit::Cover => (horizontal.max(vertical), horizontal.max(vertical)),
175                    Fit::None => (1.0, 1.0),
176                };
177                let width = f64::from(image.width()) * horizontal;
178                let height = f64::from(image.height()) * vertical;
179                let transform = motion_core::Transform::scale_non_uniform(horizontal, vertical)
180                    .then(motion_core::Transform::translate(
181                        (bounds.width() - width) * 0.5,
182                        (bounds.height() - height) * 0.5,
183                    ));
184                let mut pattern = motion_core::ImagePattern::from_asset(image)
185                    .transform(transform)
186                    .extend(motion_core::GradientExtend::Pad)
187                    .sampling(self.sampling);
188                pattern.decal = true;
189                canvas.fill(&shape, pattern, motion_core::FillRule::NonZero)
190            }
191            Err(_) => canvas.fill(
192                &shape,
193                motion_core::Color::rgb(255, 0, 255),
194                motion_core::FillRule::NonZero,
195            ),
196        }
197    }
198}
199
200/// One immutable image mip level in linear-premultiplied RGBA16F.
201#[derive(Clone, Debug, PartialEq)]
202pub struct ImageLevel {
203    width: u32,
204    height: u32,
205    pixels: Arc<[[f16; 4]]>,
206}
207
208impl ImageLevel {
209    /// Mip width in texels.
210    pub fn width(&self) -> u32 {
211        self.width
212    }
213
214    /// Mip height in texels.
215    pub fn height(&self) -> u32 {
216        self.height
217    }
218
219    /// Checked linear-premultiplied texel, expanded from shared half storage.
220    pub fn pixel(&self, column: u32, row: u32) -> Option<[f32; 4]> {
221        (column < self.width && row < self.height)
222            .then(|| self.pixels[(row * self.width + column) as usize].map(f16::to_f32))
223    }
224}
225
226/// static PNG/JPEG/WebP data with EXIF-corrected linear RGBA16F mipmaps.
227/// Clones share every level. Encoded profiles are interpreted as sRGB; ICC/HDR
228/// profile transforms and animated frame selection belong to later contracts.
229#[derive(Clone, Debug, PartialEq)]
230pub struct ImageAsset {
231    levels: Arc<[ImageLevel]>,
232}
233
234impl motion_core::pixels::ImagePixels for ImageAsset {
235    fn width(&self) -> u32 {
236        self.width()
237    }
238
239    fn height(&self) -> u32 {
240        self.height()
241    }
242
243    fn level_count(&self) -> usize {
244        self.levels.len()
245    }
246
247    fn level_size(&self, level: usize) -> (u32, u32) {
248        (self.levels[level].width(), self.levels[level].height())
249    }
250
251    fn texel(&self, level: usize, column: usize, row: usize) -> [f32; 4] {
252        self.levels[level]
253            .pixel(column as u32, row as u32)
254            .expect("bounded image texel")
255    }
256
257    fn extend_level(&self, level: usize, out: &mut Vec<f32>) {
258        let pixels = &self.levels[level].pixels;
259        out.reserve(pixels.len() * 4);
260        for pixel in pixels.iter() {
261            out.extend(pixel.map(f16::to_f32));
262        }
263    }
264
265    fn content_eq(&self, other: &dyn motion_core::pixels::ImagePixels) -> bool {
266        other
267            .as_any()
268            .downcast_ref::<Self>()
269            .is_some_and(|other| self == other)
270    }
271
272    fn as_any(&self) -> &dyn std::any::Any {
273        self
274    }
275}
276
277fn invalid(message: &str) -> motion_core::Error {
278    io::Error::new(io::ErrorKind::InvalidInput, message).into()
279}
280
281impl ImageAsset {
282    /// Reports unavailable decoding without performing I/O when codecs are disabled.
283    #[cfg(not(feature = "image"))]
284    pub fn from_bytes(_: &[u8]) -> motion_core::Result<Self> {
285        Err(motion_core::Error::from(
286            motion_core::ValidationError::unsupported(
287                "image",
288                "codec",
289                "Image decoding requires the image feature",
290            ),
291        ))
292    }
293
294    /// Decodes a bounded static PNG/JPEG/WebP and applies EXIF orientation first.
295    #[cfg(feature = "image")]
296    pub fn from_bytes(bytes: &[u8]) -> motion_core::Result<Self> {
297        if bytes.len() > super::MAX_ENCODED {
298            return Err(invalid("Encoded image exceeds 64 MiB"));
299        }
300        let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
301        if !matches!(
302            reader.format(),
303            Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP)
304        ) {
305            return Err(invalid("images require static PNG, JPEG or WebP data"));
306        }
307        if reader.format() == Some(ImageFormat::Png)
308            && image::codecs::png::PngDecoder::new(Cursor::new(bytes))
309                .map_err(crate::image_error)?
310                .is_apng()
311                .map_err(crate::image_error)?
312        {
313            return Err(invalid("Animated PNG requires the animated-image contract"));
314        }
315        if reader.format() == Some(ImageFormat::WebP)
316            && image::codecs::webp::WebPDecoder::new(Cursor::new(bytes))
317                .map_err(crate::image_error)?
318                .has_animation()
319        {
320            return Err(invalid(
321                "Animated WebP requires the animated-image contract",
322            ));
323        }
324        let mut limits = Limits::default();
325        limits.max_image_width = Some(16384);
326        limits.max_image_height = Some(16384);
327        limits.max_alloc = Some(256 * 1024 * 1024);
328        reader.limits(limits);
329        let mut decoder = reader.into_decoder().map_err(crate::image_error)?;
330        let (width, height) = decoder.dimensions();
331        super::pixel_count(width, height)?;
332        let orientation = decoder.orientation().map_err(crate::image_error)?;
333        let mut decoded = DynamicImage::from_decoder(decoder).map_err(crate::image_error)?;
334        decoded.apply_orientation(orientation);
335        let rgba = decoded.into_rgba32f();
336        let pixels = rgba
337            .pixels()
338            .map(|pixel| {
339                let alpha = f64::from(pixel.0[3]);
340                let rgb = [pixel.0[0], pixel.0[1], pixel.0[2]]
341                    .map(|value| motion_core::color::srgb_to_linear(f64::from(value)));
342                [rgb[0], rgb[1], rgb[2], alpha]
343            })
344            .collect();
345        Self::from_rgba_float(rgba.width(), rgba.height(), pixels)
346    }
347
348    /// Converts straight encoded-sRGB RGBA8 without retaining or decoding it later.
349    pub fn from_rgba8(width: u32, height: u32, pixels: Vec<u8>) -> motion_core::Result<Self> {
350        if super::pixel_count(width, height)? * 4 != pixels.len() {
351            return Err(invalid("RGBA8 byte count must equal width*height*4"));
352        }
353        let values = pixels
354            .as_chunks::<4>()
355            .0
356            .iter()
357            .map(|pixel| {
358                [
359                    motion_core::color::srgb_to_linear(f64::from(pixel[0]) / 255.0),
360                    motion_core::color::srgb_to_linear(f64::from(pixel[1]) / 255.0),
361                    motion_core::color::srgb_to_linear(f64::from(pixel[2]) / 255.0),
362                    f64::from(pixel[3]) / 255.0,
363                ]
364            })
365            .collect();
366        Self::from_rgba_float(width, height, values)
367    }
368
369    /// Takes straight LINEAR f64 RGBA, then premultiplies and rounds to half.
370    /// Extended RGB is allowed when premultiplied values fit RGBA16F; alpha must
371    /// be in 0..=1. Zero or half-rounded-zero alpha clears hidden RGB.
372    pub fn from_rgba_float(
373        width: u32,
374        height: u32,
375        pixels: Vec<[f64; 4]>,
376    ) -> motion_core::Result<Self> {
377        if super::pixel_count(width, height)? != pixels.len() {
378            return Err(invalid("RGBA float count must equal width*height"));
379        }
380        let mut base = Vec::with_capacity(pixels.len());
381        for pixel in pixels {
382            if !pixel[3].is_finite() || !(0.0..=1.0).contains(&pixel[3]) {
383                return Err(invalid("Image alpha must be finite in 0..=1"));
384            }
385            if pixel[3] == 0.0 {
386                base.push([f16::ZERO; 4]);
387                continue;
388            }
389            let pixel = [
390                pixel[0] * pixel[3],
391                pixel[1] * pixel[3],
392                pixel[2] * pixel[3],
393                pixel[3],
394            ];
395            if pixel
396                .iter()
397                .any(|value| !value.is_finite() || value.abs() > 65504.0)
398            {
399                return Err(invalid(
400                    "Premultiplied image values must fit finite RGBA16F",
401                ));
402            }
403            let mut stored = pixel.map(f16::from_f64);
404            if stored[3] == f16::ZERO {
405                stored = [f16::ZERO; 4];
406            }
407            base.push(stored);
408        }
409        let mut levels = vec![ImageLevel {
410            width,
411            height,
412            pixels: base.into(),
413        }];
414        let mut bytes = u64::from(width) * u64::from(height) * 8;
415        while levels.last().unwrap().width > 1 || levels.last().unwrap().height > 1 {
416            let previous = levels.last().unwrap();
417            let width = previous.width.div_ceil(2);
418            let height = previous.height.div_ceil(2);
419            bytes = bytes
420                .checked_add(u64::from(width) * u64::from(height) * 8)
421                .filter(|bytes| *bytes <= 256 * 1024 * 1024)
422                .ok_or_else(|| invalid("Image mip chain exceeds 256 MiB"))?;
423            let mut pixels = Vec::with_capacity(width as usize * height as usize);
424            for row in 0..height {
425                for column in 0..width {
426                    let left = f64::from(column) * f64::from(previous.width) / f64::from(width);
427                    let right =
428                        f64::from(column + 1) * f64::from(previous.width) / f64::from(width);
429                    let top = f64::from(row) * f64::from(previous.height) / f64::from(height);
430                    let bottom =
431                        f64::from(row + 1) * f64::from(previous.height) / f64::from(height);
432                    let mut sum = [0.0_f64; 4];
433                    let mut count = 0.0;
434                    for vertical in
435                        libm::floor(top) as u32..(libm::ceil(bottom) as u32).min(previous.height)
436                    {
437                        for horizontal in
438                            libm::floor(left) as u32..(libm::ceil(right) as u32).min(previous.width)
439                        {
440                            let pixel = previous.pixel(horizontal, vertical).unwrap();
441                            let weight = (right.min(f64::from(horizontal + 1))
442                                - left.max(f64::from(horizontal)))
443                                * (bottom.min(f64::from(vertical + 1))
444                                    - top.max(f64::from(vertical)));
445                            for channel in 0..4 {
446                                sum[channel] += f64::from(pixel[channel]) * weight;
447                            }
448                            count += weight;
449                        }
450                    }
451                    let mut stored = sum.map(|value| f16::from_f64(value / count));
452                    if stored[3] == f16::ZERO {
453                        stored = [f16::ZERO; 4];
454                    }
455                    pixels.push(stored);
456                }
457            }
458            levels.push(ImageLevel {
459                width,
460                height,
461                pixels: pixels.into(),
462            });
463        }
464        Ok(Self {
465            levels: levels.into(),
466        })
467    }
468
469    /// Source width after orientation.
470    pub fn width(&self) -> u32 {
471        self.levels[0].width
472    }
473
474    /// Source height after orientation.
475    pub fn height(&self) -> u32 {
476        self.levels[0].height
477    }
478
479    /// Immutable mip chain, including the base and final 1x1 level.
480    pub fn levels(&self) -> &[ImageLevel] {
481        &self.levels
482    }
483
484    /// Logical shared pixel payload, excluding allocation overhead.
485    pub fn retained_bytes(&self) -> usize {
486        self.levels.iter().map(|level| level.pixels.len() * 8).sum()
487    }
488}
489
490impl motion_core::Asset for ImageAsset {
491    const KIND: &'static str = "image";
492
493    fn decode(bytes: &[u8], _: &motion_core::LoadContext) -> motion_core::Result<Self> {
494        Self::from_bytes(bytes)
495    }
496
497    fn retained_bytes(&self) -> usize {
498        self.retained_bytes()
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    #[cfg(feature = "image")]
506    use crate::asset::AssetsExt;
507    use motion_render::RenderPrepare;
508
509    #[test]
510    #[cfg(feature = "gpu")]
511    #[ignore = "requires native GPU; no font discovery, encoder or window"]
512    fn images_match_gpu_for_all_fit_sampling_and_fractional_mips() -> motion_core::Result {
513        use motion_core::{Node, NodeModifiers, PixelSize, RasterLimits, Size, ValidationLimits};
514        let limits = RasterLimits::default();
515        let mut gpu = motion_render::gpu::Gpu::new()?;
516        let info = gpu.info().clone();
517        let pixels = (0..9)
518            .flat_map(|row| {
519                (0..17).map(move |column| {
520                    [
521                        column as f64 / 16.0,
522                        row as f64 / 8.0,
523                        if column % 2 == 0 { 1.5 } else { 0.1 },
524                        if row % 3 == 0 { 0.0 } else { 0.8 },
525                    ]
526                })
527            })
528            .collect();
529        let asset = Arc::new(ImageAsset::from_rgba_float(17, 9, pixels)?);
530        let mut maximum = 0.0_f32;
531        let mut cases = 0;
532        for size in [2.3, 5.5, 12.0] {
533            for fit in [Fit::Fill, Fit::Contain, Fit::Cover, Fit::None] {
534                for sampling in [Sampling::Nearest, Sampling::Bilinear, Sampling::Trilinear] {
535                    let image = Image::from_asset(asset.clone())
536                        .size(size, size * 0.8)
537                        .fit(fit)
538                        .corner_radius(size * 0.15)
539                        .sampling(sampling);
540                    let prepared = Node::from(image)
541                        .rotate(0.13)
542                        .translate(1.1, 2.1)
543                        .compile(
544                            Size::new(16.0, 16.0),
545                            PixelSize::new(16, 16),
546                            ValidationLimits::default(),
547                        )?
548                        .prepare(ValidationLimits::default(), limits)?;
549                    let cpu = prepared.render_linear(limits)?;
550                    let output =
551                        gpu.render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?;
552                    for row in 0..16 {
553                        for column in 0..16 {
554                            for (first, second) in cpu
555                                .pixel(column, row)
556                                .unwrap()
557                                .into_iter()
558                                .zip(output.pixel(column, row).unwrap())
559                            {
560                                maximum = maximum.max((first - second).abs());
561                            }
562                        }
563                    }
564                    let cpu = cpu.to_srgb_rgba8(false, limits)?;
565                    let output = output.to_srgb_rgba8(false, limits)?;
566                    for (first, second) in cpu.pixels().iter().zip(output.pixels()) {
567                        for (first, second) in first.iter().zip(second) {
568                            assert!(first.abs_diff(*second) <= 1);
569                        }
570                    }
571                    let replay = gpu
572                        .render(&prepared, motion_render::gpu::Plan::new(&prepared, limits)?)?
573                        .to_srgb_rgba8(false, limits)?;
574                    assert_eq!(output.pixels(), replay.pixels());
575                    cases += 1;
576                }
577            }
578        }
579        assert!(maximum <= 5e-6, "image linear difference {maximum}");
580        eprintln!(
581            "_image_gpu adapter={} backend={:?} cases={cases} source=RGBA16F odd_mips=shared max_linear_difference={maximum} rgba_tolerance=1 replay=exact",
582            info.name, info.backend
583        );
584        Ok(())
585    }
586
587    #[test]
588    #[cfg(feature = "image")]
589    fn decoded_image_handles_survive_source_file_removal() -> motion_core::Result {
590        use image::ImageEncoder;
591        let directory = tempfile::tempdir()?;
592        let path = directory.path().join("pixel.png");
593        let mut encoded = Vec::new();
594        image::codecs::png::PngEncoder::new(&mut encoded)
595            .write_image(&[255, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8)
596            .map_err(crate::image_error)?;
597        std::fs::write(&path, encoded)?;
598        let assets = motion_core::Assets::new(directory.path())?;
599        let handle = assets.image("pixel.png");
600        let loaded = handle.get()?;
601        std::fs::remove_file(path)?;
602        assert!(Arc::ptr_eq(&loaded, &handle.get()?));
603        assert_eq!(
604            render(Image::new(handle).size(1.0, 1.0), 1)?.pixel(0, 0),
605            Some([1.0, 0.0, 0.0, 1.0])
606        );
607        Ok(())
608    }
609
610    fn render(image: Image, size: u32) -> motion_core::Result<motion_render::LinearFrame> {
611        Ok(motion_core::Node::from(image)
612            .compile(
613                motion_core::Size::new(f64::from(size), f64::from(size)),
614                motion_core::PixelSize::new(size, size),
615                motion_core::ValidationLimits::default(),
616            )?
617            .prepare(
618                motion_core::ValidationLimits::default(),
619                motion_core::RasterLimits::default(),
620            )?
621            .render_linear(motion_core::RasterLimits::default())?)
622    }
623
624    #[test]
625    #[cfg(feature = "image")]
626    fn static_decoders_preserve_png_precision_webp_and_exif_orientation() -> motion_core::Result {
627        use image::ImageEncoder;
628        let mut png = Cursor::new(Vec::new());
629        let image = image::ImageBuffer::from_vec(1, 1, vec![32768_u16, 0, 0, 65535]).unwrap();
630        DynamicImage::ImageRgba16(image)
631            .write_to(&mut png, ImageFormat::Png)
632            .map_err(crate::image_error)?;
633        let decoded = ImageAsset::from_bytes(png.get_ref())?;
634        let expected = motion_core::color::srgb_to_linear(32768.0 / 65535.0);
635        assert!((f64::from(decoded.levels()[0].pixel(0, 0).unwrap()[0]) - expected).abs() < 0.0002);
636        let exif = b"II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x06\0\0\0\0\0\0\0".to_vec();
637        let mut webp = Vec::new();
638        let mut encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut webp);
639        encoder
640            .set_exif_metadata(exif.clone())
641            .map_err(io::Error::other)?;
642        encoder
643            .encode(
644                &[255, 0, 0, 255, 0, 255, 0, 255],
645                2,
646                1,
647                image::ExtendedColorType::Rgba8,
648            )
649            .map_err(crate::image_error)?;
650        let decoded = ImageAsset::from_bytes(&webp)?;
651        assert_eq!((decoded.width(), decoded.height()), (1, 2));
652        assert_eq!(decoded.levels()[0].pixel(0, 0), Some([1.0, 0.0, 0.0, 1.0]));
653        assert_eq!(decoded.levels()[0].pixel(0, 1), Some([0.0, 1.0, 0.0, 1.0]));
654        let mut jpeg = Vec::new();
655        let mut encoder = image::codecs::jpeg::JpegEncoder::new(&mut jpeg);
656        encoder.set_exif_metadata(exif).map_err(io::Error::other)?;
657        encoder
658            .encode(
659                &[
660                    255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255,
661                ],
662                3,
663                2,
664                image::ExtendedColorType::Rgb8,
665            )
666            .map_err(crate::image_error)?;
667        let decoded = ImageAsset::from_bytes(&jpeg)?;
668        assert_eq!((decoded.width(), decoded.height()), (2, 3));
669        Ok(())
670    }
671
672    #[test]
673    fn image_fit_rounding_and_mip_minification_have_independent_pixels() -> motion_core::Result {
674        let asset = Arc::new(ImageAsset::from_rgba8(
675            3,
676            1,
677            vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255],
678        )?);
679        let image = Image::from_asset(asset)
680            .size(8.0, 8.0)
681            .sampling(Sampling::Nearest);
682        let contain = render(image.clone().fit(Fit::Contain), 8)?;
683        assert_eq!(contain.pixel(4, 0), Some([0.0; 4]));
684        assert_eq!(contain.pixel(4, 3), Some([0.0, 1.0, 0.0, 1.0]));
685        let cover = render(image.clone().fit(Fit::Cover), 8)?;
686        assert_eq!(cover.pixel(0, 0), Some([0.0, 1.0, 0.0, 1.0]));
687        let fill = render(image.clone().fit(Fit::Fill), 8)?;
688        assert_eq!(fill.pixel(0, 0), Some([1.0, 0.0, 0.0, 1.0]));
689        let original = render(image.clone().fit(Fit::None), 8)?;
690        assert_eq!(original.pixel(0, 0), Some([0.0; 4]));
691        assert_eq!(original.pixel(3, 3), Some([0.0, 1.0, 0.0, 1.0]));
692        let rounded = render(image.fit(Fit::Fill).corner_radius(4.0), 8)?;
693        assert_eq!(rounded.pixel(0, 0), Some([0.0; 4]));
694        assert!(rounded.pixel(4, 4).unwrap()[3] > 0.9);
695        let mut pixels = vec![[0.0, 0.0, 0.0, 1.0]; 64];
696        pixels[0] = [1.0; 4];
697        let asset = Arc::new(ImageAsset::from_rgba_float(8, 8, pixels)?);
698        let nearest = render(
699            Image::from_asset(asset.clone())
700                .size(1.0, 1.0)
701                .sampling(Sampling::Nearest),
702            1,
703        )?;
704        let trilinear = render(
705            Image::from_asset(asset)
706                .size(1.0, 1.0)
707                .sampling(Sampling::Trilinear),
708            1,
709        )?;
710        assert_eq!(nearest.pixel(0, 0).unwrap()[0], 0.0);
711        assert_eq!(trilinear.pixel(0, 0).unwrap()[0], 1.0 / 64.0);
712        Ok(())
713    }
714
715    #[test]
716    fn half_images_build_linear_premultiplied_odd_mips_without_hidden_color() -> motion_core::Result
717    {
718        let image = ImageAsset::from_rgba8(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 0])?;
719        assert_eq!(image.levels()[1].pixel(0, 0), Some([0.5, 0.0, 0.0, 0.5]));
720        assert_eq!(image.retained_bytes(), 24);
721        let odd = ImageAsset::from_rgba8(3, 1, vec![255; 12])?;
722        assert_eq!(
723            odd.levels()
724                .iter()
725                .map(|level| (level.width(), level.height()))
726                .collect::<Vec<_>>(),
727            [(3, 1), (2, 1), (1, 1)]
728        );
729        assert_eq!(odd.levels().last().unwrap().pixel(0, 0), Some([1.0; 4]));
730        let weighted =
731            ImageAsset::from_rgba8(3, 1, vec![255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255])?;
732        let final_pixel = weighted.levels().last().unwrap().pixel(0, 0).unwrap();
733        assert!((final_pixel[0] - 2.0 / 3.0).abs() < 0.0005);
734        assert!((final_pixel[2] - 1.0 / 3.0).abs() < 0.0005);
735        let transparent =
736            ImageAsset::from_rgba_float(1, 1, vec![[f64::NAN, f64::INFINITY, 1.0, 0.0]])?;
737        assert_eq!(transparent.levels()[0].pixel(0, 0), Some([0.0; 4]));
738        assert!(ImageAsset::from_rgba_float(1, 1, vec![[1e9, 0.0, 0.0, 1.0]]).is_err());
739        Ok(())
740    }
741}