1use 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
36pub(crate) const THUMBNAIL_MAX_DIMENSIONS: FrameDimensions = FrameDimensions {
38 width: 600,
39 height: 400,
40};
41const SVG_CONTENT_TYPE: &str = "image/svg+xml";
43const WEBP_CONTENT_TYPE: &str = "image/webp";
45const WEBP_DEFAULT_QUALITY: f32 = 60.0;
47const THUMBNAIL_MAX_FILESIZE_THRESHOLD: u32 = 1024 * 1024;
57const THUMBNAIL_DIMENSIONS_THRESHOLD: u32 = 200;
62const SUPPORTED_ANIMATED_IMAGE_MIME_TYPES: &[&str] = &["image/gif", "image/png", "image/webp"];
68
69enum ImageDecoderSource {
71 Data(Vec<u8>),
73 File(File),
75}
76
77impl ImageDecoderSource {
78 const MAX_DATA_SIZE: usize = 1_048_576;
80
81 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 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 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#[derive(Clone)]
160pub(crate) struct Image {
161 file: Option<File>,
166 decoder: glycin::Image,
168 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
188pub(crate) enum ImageInfoLoader {
190 File(gio::File),
192 Texture(gdk::Texture),
194}
195
196impl ImageInfoLoader {
197 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 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 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 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 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 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#[derive(Debug, Clone)]
289enum Frame {
290 Glycin(glycin::Frame),
292 Texture(gdk::Texture),
294}
295
296impl Frame {
297 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 fn is_animated(&self) -> bool {
310 match self {
311 Self::Glycin(frame) => frame.has_delay(),
312 Self::Texture(_) => false,
313 }
314 }
315
316 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 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 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
368impl FrameDimensions {
370 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 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 pub(super) fn needs_thumbnail(self, thumbnail_dimensions: FrameDimensions) -> bool {
387 self.ge(thumbnail_dimensions.increase_by(THUMBNAIL_DIMENSIONS_THRESHOLD))
388 }
389
390 pub(super) fn downscale_for(self, max_dimensions: FrameDimensions) -> Option<Self> {
396 if !self.ge(max_dimensions) {
397 return None;
399 }
400
401 Some(self.scale_to_fit(max_dimensions, gtk::ContentFit::ScaleDown))
402 }
403
404 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#[derive(Debug, Clone)]
417pub(super) struct TextureThumbnailer(pub(super) gdk::Texture);
418
419impl TextureThumbnailer {
420 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 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 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#[derive(Debug, Clone)]
546pub(crate) struct Blurhash(pub(crate) String);
547
548impl Blurhash {
549 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 pub(crate) async fn into_texture(self, dimensions: FrameDimensions) -> Option<gdk::Texture> {
581 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#[derive(Debug, Clone, Copy)]
608pub(crate) struct ThumbnailDownloader<'a> {
609 pub(crate) main: ImageSource<'a>,
613 pub(crate) alt: Option<ImageSource<'a>>,
617}
618
619impl ThumbnailDownloader<'_> {
620 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 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 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 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 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#[derive(Debug, Clone, Copy)]
685pub(crate) struct ImageSource<'a> {
686 pub(crate) source: MediaSource<'a>,
688 pub(crate) info: Option<ImageSourceInfo<'a>>,
690}
691
692impl ImageSource<'_> {
693 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 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 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 fn filesize(&self) -> Option<u32> {
740 self.info.and_then(|i| i.filesize)
741 }
742
743 fn dimensions(&self) -> Option<FrameDimensions> {
745 self.info.and_then(|i| i.dimensions)
746 }
747
748 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
767fn filesize_is_too_big(filesize: Option<u32>) -> bool {
770 filesize.is_some_and(|s| s > THUMBNAIL_MAX_FILESIZE_THRESHOLD)
771}
772
773#[derive(Debug, Clone, Copy)]
775pub(crate) enum MediaSource<'a> {
776 Common(&'a CommonMediaSource),
778 Sticker(&'a StickerMediaSource),
780 Uri(&'a OwnedMxcUri),
782}
783
784impl MediaSource<'_> {
785 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 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#[derive(Debug, Clone, Copy, Default)]
824pub(crate) struct ImageSourceInfo<'a> {
825 dimensions: Option<FrameDimensions>,
827 mimetype: Option<&'a str>,
829 filesize: Option<u32>,
831 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#[derive(Debug, Clone)]
870pub(crate) struct ThumbnailSettings {
871 pub(crate) dimensions: FrameDimensions,
873 pub(crate) method: Method,
875 pub(crate) animated: bool,
877 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
907pub(crate) enum ImageError {
908 Download,
910 File,
912 UnsupportedFormat,
914 Unknown,
916 Aborted,
918}
919
920impl ImageError {
921 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
965pub(crate) trait GlycinFrameExt {
967 fn has_delay(&self) -> bool;
969
970 fn delay_duration(&self) -> Option<Duration>;
973
974 fn texture(&self) -> gdk::Texture;
976}
977
978impl GlycinFrameExt for glycin::Frame {
979 fn has_delay(&self) -> bool {
980 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}