Browse Source

media: Use glycin to load images

It is sandboxed so loading unknown media is safer.
It also supports more formats than image-rs.
merge-requests/1714/head
Kévin Commaille 2 years ago
parent
commit
9ff06b8a2f
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 649
      Cargo.lock
  2. 1
      Cargo.toml
  3. 59
      build-aux/org.gnome.Fractal.Devel.json
  4. 9
      meson.options
  5. 22
      src/components/avatar/editable.rs
  6. 29
      src/components/avatar/image.rs
  7. 188
      src/components/media/animated_image_paintable.rs
  8. 23
      src/components/media/content_viewer.rs
  9. 404
      src/components/media/image_paintable.rs
  10. 4
      src/components/media/mod.rs
  11. 1
      src/config.rs.in
  12. 5
      src/meson.build
  13. 2
      src/session/view/content/room_details/edit_details_subpage.rs
  14. 23
      src/session/view/content/room_details/history_viewer/visual_media_item.rs
  15. 35
      src/session/view/content/room_history/message_row/visual_media.rs
  16. 2
      src/session/view/content/room_history/message_toolbar/mod.rs
  17. 43
      src/session/view/media_viewer.rs
  18. 38
      src/utils/matrix/media_message.rs
  19. 55
      src/utils/media.rs
  20. 16
      src/utils/mod.rs

649
Cargo.lock generated

File diff suppressed because it is too large Load Diff

1
Cargo.toml

@ -52,6 +52,7 @@ url = "2"
# gtk-rs project and dependents. These usually need to be updated together. # gtk-rs project and dependents. These usually need to be updated together.
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] } adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
glycin = { version = "2.0.0-beta", default-features = false, features = ["tokio", "gdk4"] }
gst = { version = "0.23", package = "gstreamer" } gst = { version = "0.23", package = "gstreamer" }
gst_base = { version = "0.23", package = "gstreamer-base" } gst_base = { version = "0.23", package = "gstreamer-base" }
gst_gtk = { version = "0.13", package = "gst-plugin-gtk4" } gst_gtk = { version = "0.13", package = "gst-plugin-gtk4" }

59
build-aux/org.gnome.Fractal.Devel.json

@ -71,6 +71,65 @@
} }
] ]
}, },
{
"name": "libde265",
"buildsystem": "cmake",
"config-opts": [
"-DCMAKE_INSTALL_PREFIX=/app/lib/libheif-heic",
"-DENABLE_SDL=Off"
],
"sources": [
{
"type": "git",
"url": "https://github.com/strukturag/libde265.git",
"tag": "v1.0.15"
}
]
},
{
"name": "libheif",
"buildsystem": "cmake",
"config-opts": [
"-DWITH_LIBDE265_PLUGIN=On",
"-DPLUGIN_DIRECTORY=/app/lib/libheif-heic/lib",
"-DLIBDE265_INCLUDE_DIR=/app/lib/libheif-heic/include",
"-DLIBDE265_PKGCONF_LIBRARY_DIRS=/app/lib/libheif-heic/lib",
"-DWITH_X265=Off",
"-DWITH_SvtEnc=Off",
"-DWITH_SvtEnc_PLUGIN=Off",
"-DWITH_AOM_ENCODER=Off",
"-DWITH_RAV1E_PLUGIN=Off",
"-DWITH_RAV1E=Off",
"-DWITH_EXAMPLES=Off"
],
"sources": [
{
"type": "git",
"url": "https://github.com/strukturag/libheif.git",
"tag": "v1.17.6"
}
]
},
{
"name": "glycin-loaders",
"buildsystem": "meson",
"config-opts": [
"-Dtests=false",
"-Dlibglycin=false",
"-Dintrospection=false",
"-Dvapi=false",
"-Dcapi_docs=false",
"-Dpython_tests=false"
],
"sources": [
{
"type": "git",
"url": "https://gitlab.gnome.org/sophie-h/glycin.git",
"tag": "1.1.beta",
"commit": "bb5bf87ed35d3cff5fe1f094c89762e96e60c44a"
}
]
},
{ {
"name": "fractal", "name": "fractal",
"buildsystem": "meson", "buildsystem": "meson",

9
meson.options

@ -16,5 +16,12 @@ option(
value : false, value : false,
description: 'Whether the build happens in a sandbox.' + description: 'Whether the build happens in a sandbox.' +
'When that is the case, cargo will not be able to download the dependencies during' + 'When that is the case, cargo will not be able to download the dependencies during' +
'the build so they are assumed to be in meson.project_source_root()/cargo' 'the build so they are assumed to be in `{meson.project_source_root()}/cargo`.'
)
option(
'disable-glycin-sandbox',
type : 'boolean',
value : false,
description: 'Whether the sandbox of glycin should be disabled.' +
'This is only useful during development.'
) )

22
src/components/avatar/editable.rs

@ -12,9 +12,9 @@ use tracing::{debug, error};
use super::{AvatarData, AvatarImage}; use super::{AvatarData, AvatarImage};
use crate::{ use crate::{
components::{ActionButton, ActionState, ImagePaintable}, components::{ActionButton, ActionState},
toast, toast,
utils::expression, utils::{expression, media::load_image},
}; };
/// The state of the editable avatar. /// The state of the editable avatar.
@ -192,7 +192,7 @@ mod imp {
obj.set_remove_state(ActionState::Default); obj.set_remove_state(ActionState::Default);
obj.set_remove_sensitive(true); obj.set_remove_sensitive(true);
obj.set_temp_image_from_file(None); obj.set_temp_image(None);
} }
EditableAvatarState::EditInProgress => { EditableAvatarState::EditInProgress => {
obj.show_temp_image(true); obj.show_temp_image(true);
@ -207,7 +207,7 @@ mod imp {
obj.set_remove_state(ActionState::Default); obj.set_remove_state(ActionState::Default);
obj.set_remove_sensitive(true); obj.set_remove_sensitive(true);
obj.set_temp_image_from_file(None); obj.set_temp_image(None);
// Animation for success. // Animation for success.
obj.set_edit_state(ActionState::Success); obj.set_edit_state(ActionState::Success);
@ -332,11 +332,13 @@ impl EditableAvatar {
self.imp().remove_sensitive.set(sensitive); self.imp().remove_sensitive.set(sensitive);
} }
fn set_temp_image_from_file(&self, file: Option<&gio::File>) { async fn set_temp_image_from_file(&self, file: gio::File) {
self.imp().temp_image.replace( let paintable = load_image(file).await.ok();
file.and_then(|file| ImagePaintable::from_file(file).ok()) self.set_temp_image(paintable);
.map(|texture| texture.upcast()), }
);
fn set_temp_image(&self, temp_image: Option<gdk::Paintable>) {
self.imp().temp_image.replace(temp_image);
self.notify_temp_image(); self.notify_temp_image();
} }
@ -392,7 +394,7 @@ impl EditableAvatar {
.and_then(|info| info.content_type()) .and_then(|info| info.content_type())
{ {
if gio::content_type_is_a(&content_type, "image/*") { if gio::content_type_is_a(&content_type, "image/*") {
self.set_temp_image_from_file(Some(&file)); self.set_temp_image_from_file(file.clone()).await;
self.emit_by_name::<()>("edit-avatar", &[&file]); self.emit_by_name::<()>("edit-avatar", &[&file]);
} else { } else {
error!("The chosen file is not an image"); error!("The chosen file is not an image");

29
src/components/avatar/image.rs

@ -8,7 +8,11 @@ use matrix_sdk::{
}; };
use tracing::error; use tracing::error;
use crate::{components::ImagePaintable, session::model::Session, spawn, spawn_tokio}; use crate::{
session::model::Session,
spawn, spawn_tokio,
utils::{media::load_image, save_data_to_tmp_file},
};
/// The source of an avatar's URI. /// The source of an avatar's URI.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)]
@ -93,11 +97,17 @@ mod imp {
if has_uri { if has_uri {
obj.load(); obj.load();
} else { } else {
obj.set_image_data(None); self.set_paintable(None);
} }
obj.notify_uri(); obj.notify_uri();
} }
/// Set the image content as a paintable
pub(super) fn set_paintable(&self, paintable: Option<gdk::Paintable>) {
self.paintable.replace(paintable);
self.obj().notify_paintable();
}
} }
} }
@ -117,12 +127,13 @@ impl AvatarImage {
} }
/// Set the content of the image. /// Set the content of the image.
fn set_image_data(&self, data: Option<Vec<u8>>) { async fn set_image_data(&self, data: Vec<u8>) {
let paintable = data let Ok(file) = save_data_to_tmp_file(&data) else {
.and_then(|data| ImagePaintable::from_bytes(&data, None).ok()) return;
.map(|texture| texture.upcast()); };
self.imp().paintable.replace(paintable);
self.notify_paintable(); let paintable = load_image(file).await.ok();
self.imp().set_paintable(paintable);
} }
fn load(&self) { fn load(&self) {
@ -155,7 +166,7 @@ impl AvatarImage {
self, self,
async move { async move {
match handle.await.unwrap() { match handle.await.unwrap() {
Ok(data) => obj.set_image_data(Some(data)), Ok(data) => obj.set_image_data(data).await,
Err(error) => error!("Could not fetch avatar: {error}"), Err(error) => error!("Could not fetch avatar: {error}"),
}; };
} }

188
src/components/media/animated_image_paintable.rs

@ -0,0 +1,188 @@
use glycin::{Frame, Image};
use gtk::{gdk, glib, glib::clone, graphene, prelude::*, subclass::prelude::*};
use tracing::error;
use crate::{spawn, spawn_tokio};
mod imp {
use std::{
cell::{OnceCell, RefCell},
sync::Arc,
};
use super::*;
#[derive(Default)]
pub struct AnimatedImagePaintable {
/// The source image.
image: OnceCell<Arc<Image<'static>>>,
/// The current frame that is displayed.
pub current_frame: RefCell<Option<Frame>>,
/// The next frame of the animation, if any.
next_frame: RefCell<Option<Frame>>,
/// The source ID of the timeout to load the next frame, if any.
timeout_source_id: RefCell<Option<glib::SourceId>>,
}
#[glib::object_subclass]
impl ObjectSubclass for AnimatedImagePaintable {
const NAME: &'static str = "AnimatedImagePaintable";
type Type = super::AnimatedImagePaintable;
type Interfaces = (gdk::Paintable,);
}
impl ObjectImpl for AnimatedImagePaintable {}
impl PaintableImpl for AnimatedImagePaintable {
fn intrinsic_height(&self) -> i32 {
self.current_frame
.borrow()
.as_ref()
.map(|f| f.height())
.unwrap_or_else(|| self.image().info().height) as i32
}
fn intrinsic_width(&self) -> i32 {
self.current_frame
.borrow()
.as_ref()
.map(|f| f.width())
.unwrap_or_else(|| self.image().info().width) as i32
}
fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) {
if let Some(frame) = &*self.current_frame.borrow() {
frame.texture().snapshot(snapshot, width, height);
} else {
let snapshot = snapshot.downcast_ref::<gtk::Snapshot>().unwrap();
snapshot.append_color(
&gdk::RGBA::BLACK,
&graphene::Rect::new(0., 0., width as f32, height as f32),
);
}
}
fn flags(&self) -> gdk::PaintableFlags {
gdk::PaintableFlags::SIZE
}
fn current_image(&self) -> gdk::Paintable {
let snapshot = gtk::Snapshot::new();
self.snapshot(
snapshot.upcast_ref(),
self.intrinsic_width() as f64,
self.intrinsic_height() as f64,
);
snapshot
.to_paintable(None)
.expect("snapshot should always work")
}
}
impl AnimatedImagePaintable {
/// The source image.
fn image(&self) -> &Arc<Image<'static>> {
self.image.get().unwrap()
}
/// Initialize the image.
pub(super) fn init(&self, image: Image<'static>, first_frame: Frame) {
self.image.set(Arc::new(image)).unwrap();
self.current_frame.replace(Some(first_frame));
self.prepare_next_frame();
}
/// Show the next frame of the animation.
fn show_next_frame(&self) {
// Drop the timeout source ID so we know we are not waiting for it.
self.timeout_source_id.take();
let Some(next_frame) = self.next_frame.take() else {
// Wait for the next frame to be loaded.
return;
};
self.current_frame.replace(Some(next_frame));
// Invalidate the contents so that the new frame will be rendered.
self.obj().invalidate_contents();
self.prepare_next_frame();
}
/// Prepare the next frame of the animation.
fn prepare_next_frame(&self) {
let Some(delay) = self.current_frame.borrow().as_ref().and_then(|f| f.delay()) else {
return;
};
// Set the timeout to update the animation.
let source_id = glib::timeout_add_local_once(
delay,
clone!(
#[weak(rename_to = imp)]
self,
move || {
imp.show_next_frame();
}
),
);
self.timeout_source_id.replace(Some(source_id));
spawn!(clone!(
#[weak(rename_to = imp)]
self,
async move {
imp.load_next_frame_inner().await;
}
));
}
async fn load_next_frame_inner(&self) {
let image = self.image().clone();
let result = spawn_tokio!(async move { image.next_frame().await })
.await
.unwrap();
match result {
Ok(next_frame) => {
self.next_frame.replace(Some(next_frame));
// In case loading the frame took longer than the delay between frames.
if self.timeout_source_id.borrow().is_none() {
self.show_next_frame();
}
}
Err(error) => {
error!("Failed to load next frame: {error}");
// Do nothing, the animation will stop.
}
}
}
}
}
glib::wrapper! {
/// A paintable to display an animated image.
pub struct AnimatedImagePaintable(ObjectSubclass<imp::AnimatedImagePaintable>)
@implements gdk::Paintable;
}
impl AnimatedImagePaintable {
/// Load an image from the given file.
pub fn new(image: Image<'static>, first_frame: Frame) -> Self {
let obj = glib::Object::new::<Self>();
obj.imp().init(image, first_frame);
obj
}
/// Get the current `GdkTexture` of this paintable, if any.
pub fn current_texture(&self) -> Option<gdk::Texture> {
Some(self.imp().current_frame.borrow().as_ref()?.texture())
}
}

23
src/components/media/content_viewer.rs

@ -4,8 +4,8 @@ use gettextrs::gettext;
use gtk::{gdk, gio, glib, glib::clone, CompositeTemplate}; use gtk::{gdk, gio, glib, glib::clone, CompositeTemplate};
use tracing::warn; use tracing::warn;
use super::{AudioPlayer, ImagePaintable, LocationViewer}; use super::{AnimatedImagePaintable, AudioPlayer, LocationViewer};
use crate::{components::Spinner, spawn}; use crate::{components::Spinner, spawn, utils::media::load_image};
#[derive(Debug, Default, Clone, Copy)] #[derive(Debug, Default, Clone, Copy)]
pub enum ContentType { pub enum ContentType {
@ -197,13 +197,13 @@ impl MediaContentViewer {
.unwrap_or_default(); .unwrap_or_default();
match content_type { match content_type {
ContentType::Image => match ImagePaintable::from_file(&file) { ContentType::Image => match load_image(file).await {
Ok(texture) => { Ok(texture) => {
self.view_image(&texture); self.view_image(&texture);
return; return;
} }
Err(error) => { Err(error) => {
warn!("Could not load GdkTexture from file: {error}"); warn!("Could not load image from file: {error}");
} }
}, },
ContentType::Audio => { ContentType::Audio => {
@ -265,12 +265,19 @@ impl MediaContentViewer {
/// Get the texture displayed by this widget, if any. /// Get the texture displayed by this widget, if any.
pub fn texture(&self) -> Option<gdk::Texture> { pub fn texture(&self) -> Option<gdk::Texture> {
self.imp() let paintable = self
.imp()
.viewer .viewer
.child() .child()
.and_downcast::<gtk::Picture>() .and_downcast::<gtk::Picture>()
.and_then(|p| p.paintable()) .and_then(|p| p.paintable())?;
.and_downcast::<ImagePaintable>()
.and_then(|p| p.current_frame()) if let Some(paintable) = paintable.downcast_ref::<AnimatedImagePaintable>() {
paintable.current_texture()
} else if let Ok(texture) = paintable.downcast::<gdk::Texture>() {
Some(texture)
} else {
None
}
} }
} }

404
src/components/media/image_paintable.rs

@ -1,404 +0,0 @@
use std::{
io::{BufRead, BufReader, Cursor, Seek},
time::Duration,
};
use gtk::{gdk, gio, glib, graphene, prelude::*, subclass::prelude::*};
use image::{
codecs::{gif::GifDecoder, png::PngDecoder},
flat::SampleLayout,
AnimationDecoder, DynamicImage, ImageFormat,
};
use tracing::error;
/// A single frame of an animation.
pub struct Frame {
pub texture: gdk::Texture,
pub duration: Duration,
}
impl From<image::Frame> for Frame {
fn from(f: image::Frame) -> Self {
let mut duration = Duration::from(f.delay());
// The convention is to use 100 milliseconds duration if it is defined as 0.
if duration.is_zero() {
duration = Duration::from_millis(100);
}
let sample = f.into_buffer().into_flat_samples();
let texture = texture_from_data(
&sample.samples,
sample.layout,
gdk::MemoryFormat::R8g8b8a8,
image::ColorType::Rgba8.bytes_per_pixel(),
);
Frame {
texture: texture.upcast(),
duration,
}
}
}
mod imp {
use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use super::*;
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::ImagePaintable)]
pub struct ImagePaintable {
/// The frames of the animation, if any.
pub frames: RefCell<Option<Vec<Frame>>>,
/// The image if this is not an animation, otherwise this is the next
/// frame to display.
pub frame: RefCell<Option<gdk::Texture>>,
/// The current index in the animation.
pub current_idx: Cell<usize>,
/// The source ID of the timeout to load the next frame, if any.
pub timeout_source_id: RefCell<Option<glib::SourceId>>,
/// Whether this image is an animation.
#[property(get = Self::is_animation)]
pub is_animation: PhantomData<bool>,
/// The width of this image.
#[property(get = Self::intrinsic_width, default = -1)]
pub width: PhantomData<i32>,
/// The height of this image.
#[property(get = Self::intrinsic_height, default = -1)]
pub height: PhantomData<i32>,
}
#[glib::object_subclass]
impl ObjectSubclass for ImagePaintable {
const NAME: &'static str = "ImagePaintable";
type Type = super::ImagePaintable;
type Interfaces = (gdk::Paintable,);
}
#[glib::derived_properties]
impl ObjectImpl for ImagePaintable {}
impl PaintableImpl for ImagePaintable {
fn intrinsic_height(&self) -> i32 {
self.frame
.borrow()
.as_ref()
.map(|texture| texture.height())
.unwrap_or(-1)
}
fn intrinsic_width(&self) -> i32 {
self.frame
.borrow()
.as_ref()
.map(|texture| texture.width())
.unwrap_or(-1)
}
fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) {
if let Some(texture) = &*self.frame.borrow() {
texture.snapshot(snapshot, width, height);
} else {
let snapshot = snapshot.downcast_ref::<gtk::Snapshot>().unwrap();
snapshot.append_color(
&gdk::RGBA::BLACK,
&graphene::Rect::new(0f32, 0f32, width as f32, height as f32),
);
}
}
fn flags(&self) -> gdk::PaintableFlags {
if self.obj().is_animation() {
gdk::PaintableFlags::SIZE
} else {
gdk::PaintableFlags::SIZE | gdk::PaintableFlags::CONTENTS
}
}
fn current_image(&self) -> gdk::Paintable {
self.frame
.borrow()
.clone()
.map(|frame| frame.upcast())
.or_else(|| {
let snapshot = gtk::Snapshot::new();
self.obj().snapshot(&snapshot, 1.0, 1.0);
snapshot.to_paintable(None)
})
.expect("there should be a fallback paintable")
}
}
impl ImagePaintable {
/// Whether this image is an animation.
fn is_animation(&self) -> bool {
self.frames.borrow().is_some()
}
}
}
glib::wrapper! {
/// A paintable that loads images with the `image` crate.
///
/// It handles more image types than GDK-Pixbuf and can also handle
/// animations from GIF and APNG files.
pub struct ImagePaintable(ObjectSubclass<imp::ImagePaintable>)
@implements gdk::Paintable;
}
impl ImagePaintable {
/// Load an image from the given reader in the optional format.
///
/// The actual format will try to be guessed from the content.
pub fn new<R: BufRead + Seek>(
reader: R,
format: Option<ImageFormat>,
) -> Result<Self, Box<dyn std::error::Error>> {
let obj = glib::Object::new::<Self>();
let mut reader = image::io::Reader::new(reader);
if let Some(format) = format {
reader.set_format(format);
}
let reader = reader.with_guessed_format()?;
obj.load_inner(reader)?;
Ok(obj)
}
/// Load an image or animation from the given reader.
fn load_inner<R: BufRead + Seek>(
&self,
reader: image::io::Reader<R>,
) -> Result<(), Box<dyn std::error::Error>> {
let imp = self.imp();
let format = reader.format().ok_or("Could not detect image format")?;
let read = reader.into_inner();
// Handle animations.
match format {
image::ImageFormat::Gif => {
let decoder = GifDecoder::new(read)?;
let frames = decoder
.into_frames()
.collect_frames()?
.into_iter()
.map(Frame::from)
.collect::<Vec<_>>();
if frames.len() == 1 {
if let Some(frame) = frames.into_iter().next() {
imp.frame.replace(Some(frame.texture));
}
} else {
imp.frames.replace(Some(frames));
self.update_frame();
}
}
image::ImageFormat::Png => {
let decoder = PngDecoder::new(read)?;
if decoder.is_apng().unwrap_or_default() {
let decoder = decoder.apng()?;
let frames = decoder
.into_frames()
.collect_frames()?
.into_iter()
.map(Frame::from)
.collect::<Vec<_>>();
imp.frames.replace(Some(frames));
self.update_frame();
} else {
let image = DynamicImage::from_decoder(decoder)?;
self.set_image(image);
}
}
_ => {
let image = image::load(read, format)?;
self.set_image(image);
}
}
Ok(())
}
/// Set the image that is displayed by this paintable.
fn set_image(&self, image: DynamicImage) {
let texture = match image.color() {
image::ColorType::L8 | image::ColorType::Rgb8 => {
let sample = image.into_rgb8().into_flat_samples();
texture_from_data(
&sample.samples,
sample.layout,
gdk::MemoryFormat::R8g8b8,
image::ColorType::Rgb8.bytes_per_pixel(),
)
}
image::ColorType::La8 | image::ColorType::Rgba8 => {
let sample = image.into_rgba8().into_flat_samples();
texture_from_data(
&sample.samples,
sample.layout,
gdk::MemoryFormat::R8g8b8a8,
image::ColorType::Rgba8.bytes_per_pixel(),
)
}
image::ColorType::L16 | image::ColorType::Rgb16 => {
let sample = image.into_rgb16().into_flat_samples();
let bytes = sample
.samples
.into_iter()
.flat_map(|b| b.to_ne_bytes())
.collect::<Vec<_>>();
texture_from_data(
&bytes,
sample.layout,
gdk::MemoryFormat::R16g16b16,
image::ColorType::Rgb16.bytes_per_pixel(),
)
}
image::ColorType::La16 | image::ColorType::Rgba16 => {
let sample = image.into_rgba16().into_flat_samples();
let bytes = sample
.samples
.into_iter()
.flat_map(|b| b.to_ne_bytes())
.collect::<Vec<_>>();
texture_from_data(
&bytes,
sample.layout,
gdk::MemoryFormat::R16g16b16a16,
image::ColorType::Rgba16.bytes_per_pixel(),
)
}
image::ColorType::Rgb32F => {
let sample = image.into_rgb32f().into_flat_samples();
let bytes = sample
.samples
.into_iter()
.flat_map(|b| b.to_ne_bytes())
.collect::<Vec<_>>();
texture_from_data(
&bytes,
sample.layout,
gdk::MemoryFormat::R32g32b32Float,
image::ColorType::Rgb32F.bytes_per_pixel(),
)
}
image::ColorType::Rgba32F => {
let sample = image.into_rgb32f().into_flat_samples();
let bytes = sample
.samples
.into_iter()
.flat_map(|b| b.to_ne_bytes())
.collect::<Vec<_>>();
texture_from_data(
&bytes,
sample.layout,
gdk::MemoryFormat::R32g32b32Float,
image::ColorType::Rgb32F.bytes_per_pixel(),
)
}
c => {
error!("Received image of unsupported color format: {c:?}");
return;
}
};
self.imp().frame.replace(Some(texture.upcast()));
}
/// Creates a new paintable by loading an image from the given file.
pub fn from_file(file: &gio::File) -> Result<Self, Box<dyn std::error::Error>> {
let stream = file.read(gio::Cancellable::NONE)?;
let reader = BufReader::new(stream.into_read());
let format = file
.path()
.and_then(|path| ImageFormat::from_path(path).ok());
Self::new(reader, format)
}
/// Creates a new paintable by loading an image from memory.
pub fn from_bytes(
bytes: &[u8],
content_type: Option<&str>,
) -> Result<Self, Box<dyn std::error::Error>> {
let reader = Cursor::new(bytes);
let format = content_type.and_then(ImageFormat::from_mime_type);
Self::new(reader, format)
}
/// Update the current frame of the animation.
fn update_frame(&self) {
let imp = self.imp();
let frames_ref = imp.frames.borrow();
// If it's not an animation, we return early.
let frames = match &*frames_ref {
Some(frames) => frames,
None => return,
};
let idx = imp.current_idx.get();
let next_frame = frames.get(idx).unwrap();
imp.frame.replace(Some(next_frame.texture.clone()));
// Invalidate the contents so that the new frame will be rendered.
self.invalidate_contents();
// Update the frame when the duration is elapsed.
let update_frame_callback = glib::clone!(
#[weak(rename_to = obj)]
self,
move || {
obj.imp().timeout_source_id.take();
obj.update_frame();
}
);
let source_id = glib::timeout_add_local_once(next_frame.duration, update_frame_callback);
imp.timeout_source_id.replace(Some(source_id));
// Update the index for the next call.
let mut new_idx = idx + 1;
if new_idx >= frames.len() {
new_idx = 0;
}
imp.current_idx.set(new_idx);
}
/// Get the current frame of this `ImagePaintable`, if any.
pub fn current_frame(&self) -> Option<gdk::Texture> {
self.imp().frame.borrow().clone()
}
}
fn texture_from_data(
bytes: &[u8],
layout: SampleLayout,
format: gdk::MemoryFormat,
bpp: u8,
) -> gdk::MemoryTexture {
let bytes = glib::Bytes::from(bytes);
let stride = layout.width * bpp as u32;
gdk::MemoryTexture::new(
layout.width as i32,
layout.height as i32,
format,
&bytes,
stride as usize,
)
}

4
src/components/media/mod.rs

@ -1,14 +1,14 @@
mod animated_image_paintable;
mod audio_player; mod audio_player;
mod content_viewer; mod content_viewer;
mod image_paintable;
mod location_viewer; mod location_viewer;
mod video_player; mod video_player;
mod video_player_renderer; mod video_player_renderer;
pub use self::{ pub use self::{
animated_image_paintable::AnimatedImagePaintable,
audio_player::AudioPlayer, audio_player::AudioPlayer,
content_viewer::{ContentType, MediaContentViewer}, content_viewer::{ContentType, MediaContentViewer},
image_paintable::ImagePaintable,
location_viewer::LocationViewer, location_viewer::LocationViewer,
video_player::VideoPlayer, video_player::VideoPlayer,
}; };

1
src/config.rs.in

@ -1,6 +1,7 @@
use crate::application::AppProfile; use crate::application::AppProfile;
pub const APP_ID: &str = @APP_ID@; pub const APP_ID: &str = @APP_ID@;
pub const DISABLE_GLYCIN_SANDBOX: bool = @DISABLE_GLYCIN_SANDBOX@;
pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@;
pub const LOCALEDIR: &str = @LOCALEDIR@; pub const LOCALEDIR: &str = @LOCALEDIR@;
pub const PKGDATADIR: &str = @PKGDATADIR@; pub const PKGDATADIR: &str = @PKGDATADIR@;

5
src/meson.build

@ -9,11 +9,12 @@ ui_resources = gnome.compile_resources(
global_conf = configuration_data() global_conf = configuration_data()
global_conf.set_quoted('APP_ID', application_id) global_conf.set_quoted('APP_ID', application_id)
global_conf.set('DISABLE_GLYCIN_SANDBOX', get_option('disable-glycin-sandbox').to_string())
global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package)
global_conf.set_quoted('LOCALEDIR', localedir)
global_conf.set_quoted('PKGDATADIR', pkgdatadir) global_conf.set_quoted('PKGDATADIR', pkgdatadir)
global_conf.set('PROFILE', profile) global_conf.set('PROFILE', profile)
global_conf.set_quoted('VERSION', full_version) global_conf.set_quoted('VERSION', full_version)
global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package)
global_conf.set_quoted('LOCALEDIR', localedir)
config = configure_file( config = configure_file(
input: 'config.rs.in', input: 'config.rs.in',
output: 'config.rs', output: 'config.rs',

2
src/session/view/content/room_details/edit_details_subpage.rs

@ -183,7 +183,7 @@ mod imp {
} }
}; };
let base_image_info = get_image_info(&file).await; let base_image_info = get_image_info(file).await;
let image_info = assign!(ImageInfo::new(), { let image_info = assign!(ImageInfo::new(), {
width: base_image_info.width, width: base_image_info.width,
height: base_image_info.height, height: base_image_info.height,

23
src/session/view/content/room_details/history_viewer/visual_media_item.rs

@ -5,9 +5,8 @@ use tracing::warn;
use super::{HistoryViewerEvent, VisualMediaHistoryViewer}; use super::{HistoryViewerEvent, VisualMediaHistoryViewer};
use crate::{ use crate::{
components::ImagePaintable,
spawn, spawn,
utils::{add_activate_binding_action, matrix::VisualMediaMessage}, utils::{add_activate_binding_action, matrix::VisualMediaMessage, media::load_image},
}; };
/// The default size requested by a thumbnail. /// The default size requested by a thumbnail.
@ -165,21 +164,21 @@ mod imp {
((THUMBNAIL_SIZE * scale_factor) as u32).into(), ((THUMBNAIL_SIZE * scale_factor) as u32).into(),
); );
let data = media_message let file = media_message
.thumbnail(&client, settings) .thumbnail_tmp_file(&client, settings)
.await .await
.ok() .ok()
.flatten(); .flatten();
if data.is_none() && matches!(media_message, VisualMediaMessage::Video(_)) { if file.is_none() && matches!(media_message, VisualMediaMessage::Video(_)) {
// No image to show for the video. // No image to show for the video.
return; return;
} }
let data = match data { let file = match file {
Some(data) => data, Some(file) => file,
None => match media_message.into_content(&client).await { None => match media_message.into_tmp_file(&client).await {
Ok(data) => data, Ok(file) => file,
Err(error) => { Err(error) => {
warn!("Could not retrieve media file: {error}"); warn!("Could not retrieve media file: {error}");
return; return;
@ -187,9 +186,9 @@ mod imp {
}, },
}; };
match ImagePaintable::from_bytes(&data, None) { match load_image(file).await {
Ok(texture) => { Ok(paintable) => {
self.picture.set_paintable(Some(&texture)); self.picture.set_paintable(Some(&paintable));
} }
Err(error) => { Err(error) => {
warn!("Image file not supported: {error}"); warn!("Image file not supported: {error}");

35
src/session/view/content/room_history/message_row/visual_media.rs

@ -11,11 +11,11 @@ use tracing::warn;
use super::ContentFormat; use super::ContentFormat;
use crate::{ use crate::{
components::{ImagePaintable, Spinner, VideoPlayer}, components::{AnimatedImagePaintable, Spinner, VideoPlayer},
gettext_f, gettext_f,
session::model::Session, session::model::Session,
spawn, spawn,
utils::{matrix::VisualMediaMessage, LoadingState}, utils::{matrix::VisualMediaMessage, media::load_image, LoadingState},
}; };
const MAX_THUMBNAIL_WIDTH: i32 = 600; const MAX_THUMBNAIL_WIDTH: i32 = 600;
@ -313,16 +313,16 @@ impl MessageVisualMedia {
((MAX_THUMBNAIL_HEIGHT * scale_factor) as u32).into(), ((MAX_THUMBNAIL_HEIGHT * scale_factor) as u32).into(),
); );
let data = if let Some(data) = media_message let file = if let Some(file) = media_message
.thumbnail(client, settings) .thumbnail_tmp_file(client, settings)
.await .await
.ok() .ok()
.flatten() .flatten()
{ {
data file
} else { } else {
match media_message.into_content(client).await { match media_message.into_tmp_file(client).await {
Ok(data) => data, Ok(file) => file,
Err(error) => { Err(error) => {
warn!("Could not retrieve media file: {error}"); warn!("Could not retrieve media file: {error}");
imp.overlay_error imp.overlay_error
@ -334,8 +334,8 @@ impl MessageVisualMedia {
} }
}; };
match ImagePaintable::from_bytes(&data, None) { match load_image(file).await {
Ok(texture) => { Ok(paintable) => {
let child = let child =
if let Some(child) = imp.media.child().and_downcast::<gtk::Picture>() { if let Some(child) = imp.media.child().and_downcast::<gtk::Picture>() {
child child
@ -344,7 +344,7 @@ impl MessageVisualMedia {
imp.media.set_child(Some(&child)); imp.media.set_child(Some(&child));
child child
}; };
child.set_paintable(Some(&texture)); child.set_paintable(Some(&paintable));
child.set_tooltip_text(Some(&filename)); child.set_tooltip_text(Some(&filename));
if is_sticker { if is_sticker {
@ -393,13 +393,20 @@ impl MessageVisualMedia {
/// Get the texture displayed by this widget, if any. /// Get the texture displayed by this widget, if any.
pub fn texture(&self) -> Option<gdk::Texture> { pub fn texture(&self) -> Option<gdk::Texture> {
self.imp() let paintable = self
.imp()
.media .media
.child() .child()
.and_downcast::<gtk::Picture>() .and_downcast::<gtk::Picture>()
.and_then(|p| p.paintable()) .and_then(|p| p.paintable())?;
.and_downcast::<ImagePaintable>()
.and_then(|p| p.current_frame()) if let Some(paintable) = paintable.downcast_ref::<AnimatedImagePaintable>() {
paintable.current_texture()
} else if let Ok(texture) = paintable.downcast::<gdk::Texture>() {
Some(texture)
} else {
None
}
} }
} }

2
src/session/view/content/room_history/message_toolbar/mod.rs

@ -914,7 +914,7 @@ impl MessageToolbar {
let size = file_info.size.map(Into::into); let size = file_info.size.map(Into::into);
let info = match file_info.mime.type_() { let info = match file_info.mime.type_() {
mime::IMAGE => { mime::IMAGE => {
let mut info = get_image_info(&file).await; let mut info = get_image_info(file).await;
info.size = size; info.size = size;
AttachmentInfo::Image(info) AttachmentInfo::Image(info)
} }

43
src/session/view/media_viewer.rs

@ -5,7 +5,7 @@ use ruma::OwnedEventId;
use tracing::warn; use tracing::warn;
use crate::{ use crate::{
components::{ContentType, ImagePaintable, MediaContentViewer, ScaleRevealer}, components::{ContentType, MediaContentViewer, ScaleRevealer},
session::model::Room, session::model::Room,
spawn, toast, spawn, toast,
utils::matrix::VisualMediaMessage, utils::matrix::VisualMediaMessage,
@ -391,35 +391,22 @@ impl MediaViewer {
let imp = self.imp(); let imp = self.imp();
let client = session.client(); let client = session.client();
match &message { let is_video = matches!(message, VisualMediaMessage::Video(_));
VisualMediaMessage::Image(image) => {
let mimetype = image.info.as_ref().and_then(|info| info.mimetype.clone());
match message.into_content(&client).await {
Ok(data) => match ImagePaintable::from_bytes(&data, mimetype.as_deref()) {
Ok(texture) => {
imp.media.view_image(&texture);
return;
}
Err(error) => {
warn!("Could not load GdkTexture from file: {error}")
}
},
Err(error) => warn!("Could not retrieve image file: {error}"),
}
imp.media.show_fallback(ContentType::Image); match message.into_tmp_file(&client).await {
Ok(file) => {
imp.media.view_file(file);
}
Err(error) => {
warn!("Could not retrieve media file: {error}");
let content_type = if is_video {
ContentType::Video
} else {
ContentType::Image
};
imp.media.show_fallback(content_type);
} }
VisualMediaMessage::Video(_) => match message.into_tmp_file(&client).await {
Ok(file) => {
imp.media.view_file(file);
}
Err(error) => {
warn!("Could not retrieve video file: {error}");
imp.media.show_fallback(ContentType::Video);
}
},
VisualMediaMessage::Sticker(_) => unreachable!(),
} }
} }

38
src/utils/matrix/media_message.rs

@ -13,7 +13,7 @@ use ruma::{
}; };
use tracing::{debug, error}; use tracing::{debug, error};
use crate::{prelude::*, toast}; use crate::{prelude::*, toast, utils::save_data_to_tmp_file};
/// Get the filename of a media message. /// Get the filename of a media message.
macro_rules! filename { macro_rules! filename {
@ -134,17 +134,7 @@ impl MediaMessage {
/// Returns an error if something occurred while fetching the content. /// Returns an error if something occurred while fetching the content.
pub async fn into_tmp_file(self, client: &Client) -> Result<gio::File, MediaFileError> { pub async fn into_tmp_file(self, client: &Client) -> Result<gio::File, MediaFileError> {
let data = self.into_content(client).await?; let data = self.into_content(client).await?;
Ok(save_data_to_tmp_file(&data)?)
let (file, _) = gio::File::new_tmp(None::<String>)?;
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)?;
Ok(file)
} }
/// Save the content of the media to a file selected by the user. /// Save the content of the media to a file selected by the user.
@ -270,7 +260,7 @@ impl VisualMediaMessage {
} }
} }
/// Fetch the content of the media with the given client and thumbnail /// Fetch a thumbnail of the media with the given client and thumbnail
/// settings. /// settings.
/// ///
/// This might not return a thumbnail at the requested size, depending on /// This might not return a thumbnail at the requested size, depending on
@ -309,6 +299,28 @@ impl VisualMediaMessage {
} }
} }
/// Fetch a thumbnail of the media with the given client and thumbnail
/// settings and write it to a temporary file.
///
/// This might not return a thumbnail at the requested size, depending on
/// the homeserver.
///
/// Returns `Ok(None)` if no thumbnail could be retrieved. Returns an error
/// if something occurred while fetching the content.
pub async fn thumbnail_tmp_file(
&self,
client: &Client,
settings: MediaThumbnailSettings,
) -> Result<Option<gio::File>, MediaFileError> {
let data = self.thumbnail(client, settings).await?;
let Some(data) = data else {
return Ok(None);
};
Ok(Some(save_data_to_tmp_file(&data)?))
}
/// Fetch the content of the media with the given client. /// Fetch the content of the media with the given client.
/// ///
/// Returns an error if something occurred while fetching the content. /// Returns an error if something occurred while fetching the content.

55
src/utils/media.rs

@ -3,10 +3,13 @@
use std::{cell::Cell, str::FromStr, sync::Mutex}; use std::{cell::Cell, str::FromStr, sync::Mutex};
use gettextrs::gettext; use gettextrs::gettext;
use gtk::{gio, glib, prelude::*}; use glycin::Image;
use gtk::{gdk, gio, glib, prelude::*};
use matrix_sdk::attachment::{BaseAudioInfo, BaseImageInfo, BaseVideoInfo}; use matrix_sdk::attachment::{BaseAudioInfo, BaseImageInfo, BaseVideoInfo};
use mime::Mime; use mime::Mime;
use crate::{components::AnimatedImagePaintable, spawn_tokio, DISABLE_GLYCIN_SANDBOX};
/// Get a default filename for a mime type. /// Get a default filename for a mime type.
/// ///
/// Tries to guess the file extension, but it might not find it. /// Tries to guess the file extension, but it might not find it.
@ -94,7 +97,40 @@ pub async fn load_file(file: &gio::File) -> Result<(Vec<u8>, FileInfo), glib::Er
)) ))
} }
pub async fn get_image_info(file: &gio::File) -> BaseImageInfo { /// Get an image reader for the given file.
pub async fn image_reader(file: gio::File) -> Result<Image<'static>, glycin::ErrorCtx> {
let mut loader = glycin::Loader::new(file);
if DISABLE_GLYCIN_SANDBOX {
loader.sandbox_selector(glycin::SandboxSelector::NotSandboxed);
}
spawn_tokio!(async move { loader.load().await })
.await
.unwrap()
}
/// Load the given file as an image into a `GdkPaintable`.
pub async fn load_image(file: gio::File) -> Result<gdk::Paintable, glycin::ErrorCtx> {
let image = image_reader(file).await?;
let (image, first_frame) = spawn_tokio!(async move {
let first_frame = image.next_frame().await?;
Ok((image, first_frame))
})
.await
.unwrap()?;
let paintable = if first_frame.delay().is_some() {
AnimatedImagePaintable::new(image, first_frame).upcast()
} else {
first_frame.texture().upcast()
};
Ok(paintable)
}
pub async fn get_image_info(file: gio::File) -> BaseImageInfo {
let mut info = BaseImageInfo { let mut info = BaseImageInfo {
width: None, width: None,
height: None, height: None,
@ -102,17 +138,10 @@ pub async fn get_image_info(file: &gio::File) -> BaseImageInfo {
blurhash: None, blurhash: None,
}; };
let path = match file.path() { if let Ok(image) = image_reader(file).await {
Some(path) => path, let image_info = image.info();
None => return info, info.width = Some(image_info.width.into());
}; info.height = Some(image_info.height.into());
if let Some((w, h)) = image::io::Reader::open(path)
.ok()
.and_then(|reader| reader.into_dimensions().ok())
{
info.width = Some(w.into());
info.height = Some(h.into());
} }
info info

16
src/utils/mod.rs

@ -22,7 +22,7 @@ use futures_util::{
future::{self, Either, Future}, future::{self, Either, Future},
pin_mut, pin_mut,
}; };
use gtk::{gdk, glib, prelude::*, subclass::prelude::*}; use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
@ -489,3 +489,17 @@ pub fn add_activate_binding_action<T: WidgetClassExt>(klass: &mut T, action: &st
klass.add_binding_action(*key, gdk::ModifierType::empty(), action); klass.add_binding_action(*key, gdk::ModifierType::empty(), action);
} }
} }
/// Save the given data to a temporary file.
pub fn save_data_to_tmp_file(data: &[u8]) -> Result<gio::File, glib::Error> {
let (file, _) = gio::File::new_tmp(None::<String>)?;
file.replace_contents(
data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)?;
Ok(file)
}

Loading…
Cancel
Save