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