Skip to main content

fractal/utils/media/image/
mod.rs

1//! Collection of methods for images.
2
3use std::{cmp::Ordering, error::Error, fmt, str::FromStr, time::Duration};
4
5use gettextrs::gettext;
6use gtk::{gdk, gio, glib, graphene, gsk, prelude::*};
7use matrix_sdk::{
8    Client,
9    attachment::{BaseImageInfo, Thumbnail},
10    media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
11};
12use ruma::{
13    OwnedMxcUri,
14    api::client::media::get_content_thumbnail::v3::Method,
15    events::{
16        room::{
17            ImageInfo, MediaSource as CommonMediaSource, ThumbnailInfo,
18            avatar::ImageInfo as AvatarImageInfo,
19        },
20        sticker::StickerMediaSource,
21    },
22};
23use tracing::{error, warn};
24
25mod queue;
26
27pub(crate) use queue::{IMAGE_QUEUE, ImageRequestPriority};
28
29use super::{FrameDimensions, MediaFileError};
30use crate::{
31    DISABLE_GLYCIN_SANDBOX, RUNTIME,
32    components::AnimatedImagePaintable,
33    utils::{File, save_data_to_tmp_file},
34};
35
36/// The maximum dimensions of a thumbnail in the timeline.
37pub(crate) const THUMBNAIL_MAX_DIMENSIONS: FrameDimensions = FrameDimensions {
38    width: 600,
39    height: 400,
40};
41/// The content type of SVG.
42const SVG_CONTENT_TYPE: &str = "image/svg+xml";
43/// The content type of WebP.
44const WEBP_CONTENT_TYPE: &str = "image/webp";
45/// The default WebP quality used for a generated thumbnail.
46const WEBP_DEFAULT_QUALITY: f32 = 60.0;
47/// The maximum file size threshold in bytes for requesting or generating a
48/// thumbnail.
49///
50/// If the file size of the original image is larger than this, we assume it is
51/// worth it to request or generate a thumbnail, even if its dimensions are
52/// smaller than wanted. This is particularly helpful for some image formats
53/// that can take up a lot of space.
54///
55/// This is 1MB.
56const THUMBNAIL_MAX_FILESIZE_THRESHOLD: u32 = 1024 * 1024;
57/// The size threshold in pixels for requesting or generating a thumbnail.
58///
59/// If the original image is larger than dimensions + threshold, we assume it is
60/// worth it to request or generate a thumbnail.
61const THUMBNAIL_DIMENSIONS_THRESHOLD: u32 = 200;
62/// The known image MIME types that can be animated.
63///
64/// From the list of [supported image formats of glycin].
65///
66/// [supported image formats of glycin]: https://gitlab.gnome.org/GNOME/glycin/-/tree/main?ref_type=heads#supported-image-formats
67const SUPPORTED_ANIMATED_IMAGE_MIME_TYPES: &[&str] = &["image/gif", "image/png", "image/webp"];
68
69/// The source for decoding an image.
70enum ImageDecoderSource {
71    /// The bytes containing the encoded image.
72    Data(Vec<u8>),
73    /// The file containing the encoded image.
74    File(File),
75}
76
77impl ImageDecoderSource {
78    /// The maximum size of the `Data` variant. This is 1 MB.
79    const MAX_DATA_SIZE: usize = 1_048_576;
80
81    /// Construct an `ImageSource` from the given bytes.
82    ///
83    /// If the size of the bytes are too big to be kept in memory, they are
84    /// written to a temporary file.
85    async fn with_bytes(bytes: Vec<u8>) -> Result<Self, MediaFileError> {
86        if bytes.len() > Self::MAX_DATA_SIZE {
87            Ok(Self::File(save_data_to_tmp_file(bytes).await?))
88        } else {
89            Ok(Self::Data(bytes))
90        }
91    }
92
93    /// Convert this image source into a loader.
94    ///
95    /// Returns the created loader, and the image file, if any.
96    fn into_loader(self) -> (glycin::Loader, Option<File>) {
97        let (loader, file) = match self {
98            Self::Data(bytes) => (
99                glycin::Loader::for_bytes(&glib::Bytes::from_owned(bytes)),
100                None,
101            ),
102            Self::File(file) => (glycin::Loader::new(&file.as_gfile()), Some(file)),
103        };
104
105        if DISABLE_GLYCIN_SANDBOX {
106            loader.set_sandbox_selector(glycin::SandboxSelector::NotSandboxed);
107        }
108
109        (loader, file)
110    }
111
112    /// Decode this image source into an [`Image`].
113    ///
114    /// Set `request_dimensions` if the image will be shown at specific
115    /// dimensions. To show the image at its natural size, set it to `None`.
116    async fn decode_image(
117        self,
118        request_dimensions: Option<FrameDimensions>,
119    ) -> Result<Image, ImageError> {
120        let (loader, file) = self.into_loader();
121        let decoder = loader.load_future().await?;
122
123        let frame_request = request_dimensions.map(|request| {
124            let original_dimensions = FrameDimensions {
125                width: decoder.width(),
126                height: decoder.height(),
127            };
128
129            original_dimensions.to_image_loader_request(request)
130        });
131
132        let first_frame = if let Some(frame_request) = &frame_request {
133            decoder.specific_frame_future(frame_request).await?
134        } else {
135            decoder.next_frame_future().await?
136        };
137
138        Ok(Image {
139            file,
140            decoder,
141            first_frame,
142        })
143    }
144}
145
146impl From<File> for ImageDecoderSource {
147    fn from(value: File) -> Self {
148        Self::File(value)
149    }
150}
151
152impl From<gio::File> for ImageDecoderSource {
153    fn from(value: gio::File) -> Self {
154        Self::File(value.into())
155    }
156}
157
158/// An image that was just loaded.
159#[derive(Clone)]
160pub(crate) struct Image {
161    /// The file containing the image, if any.
162    ///
163    /// We need to keep a strong reference to the temporary file or it will be
164    /// destroyed.
165    file: Option<File>,
166    /// The image decoder.
167    decoder: glycin::Image,
168    /// The first frame of the image.
169    first_frame: glycin::Frame,
170}
171
172impl fmt::Debug for Image {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.debug_struct("Image").finish_non_exhaustive()
175    }
176}
177
178impl From<Image> for gdk::Paintable {
179    fn from(value: Image) -> Self {
180        if value.first_frame.has_delay() {
181            AnimatedImagePaintable::new(value.decoder, value.first_frame, value.file).upcast()
182        } else {
183            value.first_frame.texture().upcast()
184        }
185    }
186}
187
188/// An API to load image information.
189pub(crate) enum ImageInfoLoader {
190    /// An image file.
191    File(gio::File),
192    /// A texture in memory.
193    Texture(gdk::Texture),
194}
195
196impl ImageInfoLoader {
197    /// Load the first frame for this source.
198    ///
199    /// We need to load the first frame of an image so that EXIF rotation is
200    /// applied and we get the proper dimensions.
201    async fn into_first_frame(self) -> Option<Frame> {
202        match self {
203            Self::File(file) => {
204                let (loader, _) = ImageDecoderSource::from(file).into_loader();
205                let frame = loader
206                    .load_future()
207                    .await
208                    .ok()?
209                    .next_frame_future()
210                    .await
211                    .ok()?;
212
213                Some(Frame::Glycin(frame))
214            }
215            Self::Texture(texture) => Some(Frame::Texture(texture)),
216        }
217    }
218
219    /// Load the information for this image.
220    pub(crate) async fn load_info(self) -> BaseImageInfo {
221        self.into_first_frame()
222            .await
223            .map(|f| f.info())
224            .unwrap_or_default()
225    }
226
227    /// Load the information for this image and try to generate a thumbnail
228    /// given the filesize of the original image.
229    pub(crate) async fn load_info_and_thumbnail(
230        self,
231        filesize: Option<u32>,
232        widget: &impl IsA<gtk::Widget>,
233    ) -> (BaseImageInfo, Option<Thumbnail>) {
234        let Some(frame) = self.into_first_frame().await else {
235            return (BaseImageInfo::default(), None);
236        };
237
238        let mut info = frame.info();
239
240        // Generate the same thumbnail dimensions as we will need in the timeline.
241        let scale_factor = widget.scale_factor();
242        let max_thumbnail_dimensions =
243            FrameDimensions::thumbnail_max_dimensions(widget.scale_factor());
244
245        if !filesize_is_too_big(filesize)
246            && !frame
247                .dimensions()
248                .is_some_and(|d| d.needs_thumbnail(max_thumbnail_dimensions))
249        {
250            // It is not worth it to generate a thumbnail.
251            info.blurhash = frame.generate_blurhash().map(|blurhash| blurhash.0);
252
253            return (info, None);
254        }
255
256        let Some(renderer) = widget
257            .root()
258            .and_downcast::<gtk::Window>()
259            .and_then(|w| w.renderer())
260        else {
261            // We cannot generate a thumbnail.
262            error!("Could not get GdkRenderer");
263            return (info, None);
264        };
265
266        let (thumbnail, blurhash) = frame
267            .generate_thumbnail_and_blurhash(scale_factor, &renderer)
268            .unzip();
269        info.blurhash = blurhash.map(|blurhash| blurhash.0);
270
271        (info, thumbnail)
272    }
273}
274
275impl From<gio::File> for ImageInfoLoader {
276    fn from(value: gio::File) -> Self {
277        Self::File(value)
278    }
279}
280
281impl From<gdk::Texture> for ImageInfoLoader {
282    fn from(value: gdk::Texture) -> Self {
283        Self::Texture(value)
284    }
285}
286
287/// A frame of an image.
288#[derive(Debug, Clone)]
289enum Frame {
290    /// A frame loaded via glycin.
291    Glycin(glycin::Frame),
292    /// A texture in memory,
293    Texture(gdk::Texture),
294}
295
296impl Frame {
297    /// The dimensions of the frame.
298    fn dimensions(&self) -> Option<FrameDimensions> {
299        match self {
300            Self::Glycin(frame) => Some(FrameDimensions {
301                width: frame.width(),
302                height: frame.height(),
303            }),
304            Self::Texture(texture) => FrameDimensions::with_texture(texture),
305        }
306    }
307
308    /// Whether the image that this frame belongs to is animated.
309    fn is_animated(&self) -> bool {
310        match self {
311            Self::Glycin(frame) => frame.has_delay(),
312            Self::Texture(_) => false,
313        }
314    }
315
316    /// Get the `BaseImageInfo` for this frame.
317    fn info(&self) -> BaseImageInfo {
318        let dimensions = self.dimensions();
319        BaseImageInfo {
320            width: dimensions.map(|d| d.width.into()),
321            height: dimensions.map(|d| d.height.into()),
322            is_animated: Some(self.is_animated()),
323            ..Default::default()
324        }
325    }
326
327    /// Generate a Blurhash of this frame.
328    fn generate_blurhash(self) -> Option<Blurhash> {
329        let texture = match self {
330            Self::Glycin(frame) => frame.texture(),
331            Self::Texture(texture) => texture,
332        };
333
334        let blurhash = Blurhash::with_texture(&texture);
335
336        if blurhash.is_none() {
337            warn!("Could not generate Blurhash from GdkTexture");
338        }
339
340        blurhash
341    }
342
343    /// Generate a thumbnail and a Blurhash of this frame.
344    ///
345    /// We use the thumbnail to compute the blurhash, which should be less
346    /// expensive than using the original frame.
347    fn generate_thumbnail_and_blurhash(
348        self,
349        scale_factor: i32,
350        renderer: &gsk::Renderer,
351    ) -> Option<(Thumbnail, Blurhash)> {
352        let texture = match self {
353            Self::Glycin(frame) => frame.texture(),
354            Self::Texture(texture) => texture,
355        };
356
357        let thumbnail_blurhash =
358            TextureThumbnailer(texture).generate_thumbnail_and_blurhash(scale_factor, renderer);
359
360        if thumbnail_blurhash.is_none() {
361            warn!("Could not generate thumbnail and Blurhash from GdkTexture");
362        }
363
364        thumbnail_blurhash
365    }
366}
367
368/// Extensions to `FrameDimensions` for computing thumbnail dimensions.
369impl FrameDimensions {
370    /// Get the maximum dimensions for a thumbnail with the given scale factor.
371    pub(crate) fn thumbnail_max_dimensions(scale_factor: i32) -> Self {
372        let scale_factor = scale_factor.try_into().unwrap_or(1);
373        THUMBNAIL_MAX_DIMENSIONS.scale(scale_factor)
374    }
375
376    /// Construct a `FrameDimensions` for the given texture.
377    fn with_texture(texture: &gdk::Texture) -> Option<Self> {
378        Some(Self {
379            width: texture.width().try_into().ok()?,
380            height: texture.height().try_into().ok()?,
381        })
382    }
383
384    /// Whether we should generate or request a thumbnail for these dimensions,
385    /// given the wanted thumbnail dimensions.
386    pub(super) fn needs_thumbnail(self, thumbnail_dimensions: FrameDimensions) -> bool {
387        self.ge(thumbnail_dimensions.increase_by(THUMBNAIL_DIMENSIONS_THRESHOLD))
388    }
389
390    /// Downscale these dimensions to fit into the given maximum dimensions
391    /// while preserving the aspect ratio.
392    ///
393    /// Returns `None` if these dimensions are smaller than the maximum
394    /// dimensions.
395    pub(super) fn downscale_for(self, max_dimensions: FrameDimensions) -> Option<Self> {
396        if !self.ge(max_dimensions) {
397            // We do not need to downscale.
398            return None;
399        }
400
401        Some(self.scale_to_fit(max_dimensions, gtk::ContentFit::ScaleDown))
402    }
403
404    /// Convert these dimensions to a request for the image loader with the
405    /// requested dimensions.
406    fn to_image_loader_request(self, requested: Self) -> glycin::FrameRequest {
407        let scaled = self.scale_to_fit(requested, gtk::ContentFit::Cover);
408
409        let request = glycin::FrameRequest::new();
410        request.set_scale(scaled.width, scaled.height);
411        request
412    }
413}
414
415/// A thumbnailer for a `GdkTexture`.
416#[derive(Debug, Clone)]
417pub(super) struct TextureThumbnailer(pub(super) gdk::Texture);
418
419impl TextureThumbnailer {
420    /// Downscale the texture if needed to fit into the given maximum thumbnail
421    /// dimensions.
422    ///
423    /// Returns `None` if the dimensions of the texture are unknown.
424    fn downscale_texture_if_needed(
425        self,
426        max_dimensions: FrameDimensions,
427        renderer: &gsk::Renderer,
428    ) -> Option<gdk::Texture> {
429        let dimensions = FrameDimensions::with_texture(&self.0)?;
430
431        let texture = if let Some(target_dimensions) = dimensions.downscale_for(max_dimensions) {
432            let snapshot = gtk::Snapshot::new();
433            let bounds = graphene::Rect::new(
434                0.0,
435                0.0,
436                target_dimensions.width as f32,
437                target_dimensions.height as f32,
438            );
439            snapshot.append_texture(&self.0, &bounds);
440            let node = snapshot.to_node()?;
441            renderer.render_texture(node, None)
442        } else {
443            self.0
444        };
445
446        Some(texture)
447    }
448
449    /// Convert the given texture memory format to the format needed to make a
450    /// thumbnail.
451    ///
452    /// The WebP encoder only supports RGB and RGBA.
453    ///
454    /// Returns `None` if the format is unknown.
455    fn texture_format_to_thumbnail_format(
456        format: gdk::MemoryFormat,
457    ) -> Option<(gdk::MemoryFormat, webp::PixelLayout)> {
458        match format {
459            gdk::MemoryFormat::B8g8r8a8Premultiplied
460            | gdk::MemoryFormat::A8r8g8b8Premultiplied
461            | gdk::MemoryFormat::R8g8b8a8Premultiplied
462            | gdk::MemoryFormat::B8g8r8a8
463            | gdk::MemoryFormat::A8r8g8b8
464            | gdk::MemoryFormat::R8g8b8a8
465            | gdk::MemoryFormat::R16g16b16a16Premultiplied
466            | gdk::MemoryFormat::R16g16b16a16
467            | gdk::MemoryFormat::R16g16b16a16FloatPremultiplied
468            | gdk::MemoryFormat::R16g16b16a16Float
469            | gdk::MemoryFormat::R32g32b32a32FloatPremultiplied
470            | gdk::MemoryFormat::R32g32b32a32Float
471            | gdk::MemoryFormat::G8a8Premultiplied
472            | gdk::MemoryFormat::G8a8
473            | gdk::MemoryFormat::G16a16Premultiplied
474            | gdk::MemoryFormat::G16a16
475            | gdk::MemoryFormat::A8
476            | gdk::MemoryFormat::A16
477            | gdk::MemoryFormat::A16Float
478            | gdk::MemoryFormat::A32Float
479            | gdk::MemoryFormat::A8b8g8r8Premultiplied
480            | gdk::MemoryFormat::A8b8g8r8 => {
481                Some((gdk::MemoryFormat::R8g8b8a8, webp::PixelLayout::Rgba))
482            }
483            gdk::MemoryFormat::R8g8b8
484            | gdk::MemoryFormat::B8g8r8
485            | gdk::MemoryFormat::R16g16b16
486            | gdk::MemoryFormat::R16g16b16Float
487            | gdk::MemoryFormat::R32g32b32Float
488            | gdk::MemoryFormat::G8
489            | gdk::MemoryFormat::G16
490            | gdk::MemoryFormat::B8g8r8x8
491            | gdk::MemoryFormat::X8r8g8b8
492            | gdk::MemoryFormat::R8g8b8x8
493            | gdk::MemoryFormat::X8b8g8r8 => {
494                Some((gdk::MemoryFormat::R8g8b8, webp::PixelLayout::Rgb))
495            }
496            _ => None,
497        }
498    }
499
500    /// Generate the thumbnail for the given scale factor, with the given
501    /// `GskRenderer`, and a Blurhash.
502    ///
503    /// We use the thumbnail to compute the blurhash, which should be less
504    /// expensive than using the original texture.
505    pub(super) fn generate_thumbnail_and_blurhash(
506        self,
507        scale_factor: i32,
508        renderer: &gsk::Renderer,
509    ) -> Option<(Thumbnail, Blurhash)> {
510        let max_thumbnail_dimensions = FrameDimensions::thumbnail_max_dimensions(scale_factor);
511        let thumbnail = self.downscale_texture_if_needed(max_thumbnail_dimensions, renderer)?;
512        let dimensions = FrameDimensions::with_texture(&thumbnail)?;
513
514        let blurhash = Blurhash::with_texture(&thumbnail)?;
515
516        let (downloader_format, webp_layout) =
517            Self::texture_format_to_thumbnail_format(thumbnail.format())?;
518
519        let mut downloader = gdk::TextureDownloader::new(&thumbnail);
520        downloader.set_format(downloader_format);
521        let (data, _) = downloader.download_bytes();
522
523        let encoder = webp::Encoder::new(&data, webp_layout, dimensions.width, dimensions.height);
524        let data = encoder.encode(WEBP_DEFAULT_QUALITY).to_vec();
525
526        let size = data.len().try_into().ok()?;
527        let content_type =
528            mime::Mime::from_str(WEBP_CONTENT_TYPE).expect("content type should be valid");
529
530        let thumbnail = Thumbnail {
531            data,
532            content_type,
533            width: dimensions.width.into(),
534            height: dimensions.height.into(),
535            size,
536        };
537
538        Some((thumbnail, blurhash))
539    }
540}
541
542/// A [Blurhash].
543///
544/// [Blurhash]: https://blurha.sh/
545#[derive(Debug, Clone)]
546pub(crate) struct Blurhash(pub(crate) String);
547
548impl Blurhash {
549    /// Try to compute the Blurhash for the given `GdkTexture`.
550    pub(super) fn with_texture(texture: &gdk::Texture) -> Option<Self> {
551        let dimensions = FrameDimensions::with_texture(texture)?;
552
553        let mut downloader = gdk::TextureDownloader::new(texture);
554        downloader.set_format(gdk::MemoryFormat::R8g8b8a8);
555        let (data, _) = downloader.download_bytes();
556
557        let (components_x, components_y) = match dimensions.width.cmp(&dimensions.height) {
558            Ordering::Less => (3, 4),
559            Ordering::Equal => (3, 3),
560            Ordering::Greater => (4, 3),
561        };
562
563        let hash = blurhash::encode(
564            components_x,
565            components_y,
566            dimensions.width,
567            dimensions.height,
568            &data,
569        )
570        .inspect_err(|error| {
571            warn!("Could not encode Blurhash: {error}");
572        })
573        .ok()?;
574
575        Some(Self(hash))
576    }
577
578    /// Try to convert this Blurhash to a `GdkTexture` with the given
579    /// dimensions.
580    pub(crate) async fn into_texture(self, dimensions: FrameDimensions) -> Option<gdk::Texture> {
581        // Because it can take some time, spawn on a separate thread.
582        RUNTIME
583            .spawn_blocking(move || {
584                let data = blurhash::decode(&self.0, dimensions.width, dimensions.height, 1.0)
585                    .inspect_err(|error| {
586                        warn!("Could not decode Blurhash: {error}");
587                    })
588                    .ok()?;
589
590                Some(
591                    gdk::MemoryTexture::new(
592                        dimensions.width.try_into().ok()?,
593                        dimensions.height.try_into().ok()?,
594                        gdk::MemoryFormat::R8g8b8a8,
595                        &glib::Bytes::from_owned(data),
596                        4 * dimensions.width as usize,
597                    )
598                    .upcast(),
599                )
600            })
601            .await
602            .expect("task was not aborted")
603    }
604}
605
606/// An API to download a thumbnail for a media.
607#[derive(Debug, Clone, Copy)]
608pub(crate) struct ThumbnailDownloader<'a> {
609    /// The main source of the image.
610    ///
611    /// This should be the source with the best quality.
612    pub(crate) main: ImageSource<'a>,
613    /// An alternative source for the image.
614    ///
615    /// This should be a source with a lower quality.
616    pub(crate) alt: Option<ImageSource<'a>>,
617}
618
619impl ThumbnailDownloader<'_> {
620    /// Download the thumbnail of the media.
621    ///
622    /// This might not return a thumbnail at the requested dimensions, depending
623    /// on the sources and the homeserver.
624    pub(crate) async fn download(
625        self,
626        client: Client,
627        settings: ThumbnailSettings,
628        priority: ImageRequestPriority,
629    ) -> Result<Image, ImageError> {
630        let dimensions = settings.dimensions;
631
632        // First, select which source we are going to download from.
633        let source = if let Some(alt) = self.alt {
634            let is_animated = settings.animated && self.main.is_animated();
635
636            if !is_animated
637                && !self.main.can_be_thumbnailed()
638                && (filesize_is_too_big(self.main.filesize())
639                    || alt.dimensions().is_some_and(|s| s.ge(settings.dimensions)))
640            {
641                // Use the alternative source to save bandwidth.
642                alt
643            } else {
644                self.main
645            }
646        } else {
647            self.main
648        };
649
650        if source.should_thumbnail(
651            settings.prefer_thumbnail,
652            settings.animated,
653            settings.dimensions,
654        ) {
655            // Try to get a thumbnail.
656            let request = MediaRequestParameters {
657                source: source.source.to_common_media_source(),
658                format: MediaFormat::Thumbnail(settings.into()),
659            };
660            let handle = IMAGE_QUEUE.add_download_request(
661                client.clone(),
662                request,
663                Some(dimensions),
664                priority,
665            );
666
667            if let Ok(image) = handle.await {
668                return Ok(image);
669            }
670        }
671
672        // Fallback to downloading the full source.
673        let request = MediaRequestParameters {
674            source: source.source.to_common_media_source(),
675            format: MediaFormat::File,
676        };
677        let handle = IMAGE_QUEUE.add_download_request(client, request, Some(dimensions), priority);
678
679        handle.await
680    }
681}
682
683/// The source of an image.
684#[derive(Debug, Clone, Copy)]
685pub(crate) struct ImageSource<'a> {
686    /// The source of the image.
687    pub(crate) source: MediaSource<'a>,
688    /// Information about the image.
689    pub(crate) info: Option<ImageSourceInfo<'a>>,
690}
691
692impl ImageSource<'_> {
693    /// Whether we should try to thumbnail this source for the given requested
694    /// dimensions.
695    fn should_thumbnail(
696        &self,
697        prefer_thumbnail: bool,
698        prefer_animated: bool,
699        thumbnail_dimensions: FrameDimensions,
700    ) -> bool {
701        if !self.can_be_thumbnailed() {
702            return false;
703        }
704
705        // Even if we request animated thumbnails, not a lot of media repositories
706        // support scaling animated images. So we just download the original to be able
707        // to play it.
708        if prefer_animated && self.is_animated() {
709            return false;
710        }
711
712        let dimensions = self.dimensions();
713
714        if prefer_thumbnail && dimensions.is_none() {
715            return true;
716        }
717
718        dimensions.is_some_and(|d| d.needs_thumbnail(thumbnail_dimensions))
719            || filesize_is_too_big(self.filesize())
720    }
721
722    /// Whether this source can be thumbnailed by the media repo.
723    ///
724    /// Returns `false` in these cases:
725    ///
726    /// - The image is encrypted, because it is not possible for the media repo
727    ///   to make a thumbnail.
728    /// - The image uses the SVG format, because media repos usually do not
729    ///   accept to create a thumbnail of those.
730    fn can_be_thumbnailed(&self) -> bool {
731        !self.source.is_encrypted()
732            && self
733                .info
734                .and_then(|i| i.mimetype)
735                .is_none_or(|m| m != SVG_CONTENT_TYPE)
736    }
737
738    /// The filesize of this source.
739    fn filesize(&self) -> Option<u32> {
740        self.info.and_then(|i| i.filesize)
741    }
742
743    /// The dimensions of this source.
744    fn dimensions(&self) -> Option<FrameDimensions> {
745        self.info.and_then(|i| i.dimensions)
746    }
747
748    /// Whether this source is animated.
749    ///
750    /// Returns `false` if the info does not say that it is animated, or if the
751    /// MIME type is not one of the supported animated image formats.
752    fn is_animated(&self) -> bool {
753        if self
754            .info
755            .and_then(|i| i.is_animated)
756            .is_none_or(|is_animated| !is_animated)
757        {
758            return false;
759        }
760
761        self.info
762            .and_then(|i| i.mimetype)
763            .is_some_and(|mimetype| SUPPORTED_ANIMATED_IMAGE_MIME_TYPES.contains(&mimetype))
764    }
765}
766
767/// Whether the given filesize is considered too big to be the preferred source
768/// to download.
769fn filesize_is_too_big(filesize: Option<u32>) -> bool {
770    filesize.is_some_and(|s| s > THUMBNAIL_MAX_FILESIZE_THRESHOLD)
771}
772
773/// The source of a media file.
774#[derive(Debug, Clone, Copy)]
775pub(crate) enum MediaSource<'a> {
776    /// A common media source.
777    Common(&'a CommonMediaSource),
778    /// The media source of a sticker.
779    Sticker(&'a StickerMediaSource),
780    /// An MXC URI.
781    Uri(&'a OwnedMxcUri),
782}
783
784impl MediaSource<'_> {
785    /// Whether this source is encrypted.
786    fn is_encrypted(&self) -> bool {
787        match self {
788            Self::Common(source) => matches!(source, CommonMediaSource::Encrypted(_)),
789            Self::Sticker(source) => matches!(source, StickerMediaSource::Encrypted(_)),
790            Self::Uri(_) => false,
791        }
792    }
793
794    /// Get this source as a `CommonMediaSource`.
795    fn to_common_media_source(self) -> CommonMediaSource {
796        match self {
797            Self::Common(source) => source.clone(),
798            Self::Sticker(source) => source.clone().into(),
799            Self::Uri(uri) => CommonMediaSource::Plain(uri.clone()),
800        }
801    }
802}
803
804impl<'a> From<&'a CommonMediaSource> for MediaSource<'a> {
805    fn from(value: &'a CommonMediaSource) -> Self {
806        Self::Common(value)
807    }
808}
809
810impl<'a> From<&'a StickerMediaSource> for MediaSource<'a> {
811    fn from(value: &'a StickerMediaSource) -> Self {
812        Self::Sticker(value)
813    }
814}
815
816impl<'a> From<&'a OwnedMxcUri> for MediaSource<'a> {
817    fn from(value: &'a OwnedMxcUri) -> Self {
818        Self::Uri(value)
819    }
820}
821
822/// Information about the source of an image.
823#[derive(Debug, Clone, Copy, Default)]
824pub(crate) struct ImageSourceInfo<'a> {
825    /// The dimensions of the image.
826    dimensions: Option<FrameDimensions>,
827    /// The MIME type of the image.
828    mimetype: Option<&'a str>,
829    /// The file size of the image.
830    filesize: Option<u32>,
831    /// Whether the image is animated.
832    is_animated: Option<bool>,
833}
834
835impl<'a> From<&'a ImageInfo> for ImageSourceInfo<'a> {
836    fn from(value: &'a ImageInfo) -> Self {
837        Self {
838            dimensions: FrameDimensions::from_options(value.width, value.height),
839            mimetype: value.mimetype.as_deref(),
840            filesize: value.size.and_then(|u| u.try_into().ok()),
841            is_animated: value.is_animated,
842        }
843    }
844}
845
846impl<'a> From<&'a ThumbnailInfo> for ImageSourceInfo<'a> {
847    fn from(value: &'a ThumbnailInfo) -> Self {
848        Self {
849            dimensions: FrameDimensions::from_options(value.width, value.height),
850            mimetype: value.mimetype.as_deref(),
851            filesize: value.size.and_then(|u| u.try_into().ok()),
852            is_animated: None,
853        }
854    }
855}
856
857impl<'a> From<&'a AvatarImageInfo> for ImageSourceInfo<'a> {
858    fn from(value: &'a AvatarImageInfo) -> Self {
859        Self {
860            dimensions: FrameDimensions::from_options(value.width, value.height),
861            mimetype: value.mimetype.as_deref(),
862            filesize: value.size.and_then(|u| u.try_into().ok()),
863            is_animated: None,
864        }
865    }
866}
867
868/// The settings for downloading a thumbnail.
869#[derive(Debug, Clone)]
870pub(crate) struct ThumbnailSettings {
871    /// The requested dimensions of the thumbnail.
872    pub(crate) dimensions: FrameDimensions,
873    /// The method to use to resize the thumbnail.
874    pub(crate) method: Method,
875    /// Whether to request an animated thumbnail.
876    pub(crate) animated: bool,
877    /// Whether we should prefer to get a thumbnail if dimensions are unknown.
878    ///
879    /// This is particularly useful for avatars where we will prefer to save
880    /// bandwidth and memory usage as we download a lot of them and they might
881    /// appear several times on the screen. For media messages, we will on the
882    /// contrary prefer to download the original content to reduce the space
883    /// taken in the media cache.
884    pub(crate) prefer_thumbnail: bool,
885}
886
887impl From<ThumbnailSettings> for MediaThumbnailSettings {
888    fn from(value: ThumbnailSettings) -> Self {
889        let ThumbnailSettings {
890            dimensions,
891            method,
892            animated,
893            ..
894        } = value;
895
896        MediaThumbnailSettings {
897            method,
898            width: dimensions.width.into(),
899            height: dimensions.height.into(),
900            animated,
901        }
902    }
903}
904
905/// An error encountered when loading an image.
906#[derive(Debug, Clone, Copy, PartialEq, Eq)]
907pub(crate) enum ImageError {
908    /// Could not download the image.
909    Download,
910    /// Could not save the image to a temporary file.
911    File,
912    /// The image uses an unsupported format.
913    UnsupportedFormat,
914    /// An unexpected error occurred.
915    Unknown,
916    /// The request for the image was aborted.
917    Aborted,
918}
919
920impl ImageError {
921    /// Log the given image error.
922    fn log_error(error: impl fmt::Display) {
923        warn!("Could not decode image: {error}");
924    }
925}
926
927impl Error for ImageError {}
928
929impl fmt::Display for ImageError {
930    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
931        let s = match self {
932            Self::Download => gettext("Could not retrieve media"),
933            Self::UnsupportedFormat => gettext("Image format not supported"),
934            Self::File | Self::Unknown | Self::Aborted => gettext("An unexpected error occurred"),
935        };
936
937        f.write_str(&s)
938    }
939}
940
941impl From<MediaFileError> for ImageError {
942    fn from(value: MediaFileError) -> Self {
943        Self::log_error(&value);
944
945        match value {
946            MediaFileError::Sdk(_) => Self::Download,
947            MediaFileError::File(_) => Self::File,
948            MediaFileError::NoSession => Self::Unknown,
949        }
950    }
951}
952
953impl From<glib::Error> for ImageError {
954    fn from(value: glib::Error) -> Self {
955        Self::log_error(&value);
956
957        if let Some(glycin::LoaderError::UnknownImageFormat) = value.kind() {
958            Self::UnsupportedFormat
959        } else {
960            Self::Unknown
961        }
962    }
963}
964
965/// Extensions to [`glycin::Frame`].
966pub(crate) trait GlycinFrameExt {
967    /// Whether the frame has a delay, which means that the image is animated.
968    fn has_delay(&self) -> bool;
969
970    /// How long to show this frame for if the image is animated, as a
971    /// [`Duration`].
972    fn delay_duration(&self) -> Option<Duration>;
973
974    /// Convert this frame to a [`gdk::Texture`].
975    fn texture(&self) -> gdk::Texture;
976}
977
978impl GlycinFrameExt for glycin::Frame {
979    fn has_delay(&self) -> bool {
980        // glycin always computes a suitable delay if the image is animated but its
981        // delay is set to 0, so 0 should mean that the image is not animated.
982        self.delay() > 0
983    }
984
985    fn delay_duration(&self) -> Option<Duration> {
986        self.has_delay()
987            .then(|| u64::try_from(self.delay()).ok())
988            .flatten()
989            .map(Duration::from_millis)
990    }
991
992    fn texture(&self) -> gdk::Texture {
993        glycin_gtk4::frame_get_texture(self)
994    }
995}