Browse Source

Gif support (#4620)

* Previous PR: #3951 
* Closes #4489

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
pull/4678/head
JustFrederik 5 months ago
committed by GitHub
parent
commit
52a8e11764
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      Cargo.lock
  2. 7
      crates/egui/src/widgets/button.rs
  3. 78
      crates/egui/src/widgets/image.rs
  4. 2
      crates/egui/src/widgets/image_button.rs
  5. 5
      crates/egui/src/widgets/mod.rs
  6. 5
      crates/egui_extras/Cargo.toml
  7. 9
      crates/egui_extras/src/loaders.rs
  8. 134
      crates/egui_extras/src/loaders/gif_loader.rs
  9. BIN
      examples/images/src/ferris.gif
  10. 1
      examples/images/src/main.rs

24
Cargo.lock

@ -819,6 +819,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecdffb913a326b6c642290a0d0ec8e8d6597291acdc07cc4c9cb4b3635d44cf9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "com"
version = "0.6.0"
@ -1734,6 +1740,16 @@ dependencies = [
"wasi",
]
[[package]]
name = "gif"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gimli"
version = "0.28.0"
@ -2079,6 +2095,8 @@ checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"gif",
"num-traits",
"png",
"zune-core",
@ -4240,6 +4258,12 @@ version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc"
[[package]]
name = "weezl"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
[[package]]
name = "wgpu"
version = "0.20.1"

7
crates/egui/src/widgets/button.rs

@ -319,8 +319,11 @@ impl Widget for Button<'_> {
image.show_loading_spinner,
image.image_options(),
);
response =
widgets::image::texture_load_result_response(image.source(), &tlr, response);
response = widgets::image::texture_load_result_response(
&image.source(ui.ctx()),
&tlr,
response,
);
}
if image.is_some() && galley.is_some() {

78
crates/egui/src/widgets/image.rs

@ -1,4 +1,4 @@
use std::borrow::Cow;
use std::{borrow::Cow, sync::Arc, time::Duration};
use emath::{Float as _, Rot2};
use epaint::RectShape;
@ -40,6 +40,7 @@ use crate::{
/// .paint_at(ui, rect);
/// # });
/// ```
///
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
#[derive(Debug, Clone)]
pub struct Image<'a> {
@ -288,8 +289,20 @@ impl<'a> Image<'a> {
}
#[inline]
pub fn source(&self) -> &ImageSource<'a> {
&self.source
pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> {
match &self.source {
ImageSource::Uri(uri) if is_gif_uri(uri) => {
let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri));
ImageSource::Uri(Cow::Owned(frame_uri))
}
ImageSource::Bytes { uri, bytes } if is_gif_uri(uri) || has_gif_magic_header(bytes) => {
let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri));
ctx.include_bytes(uri.clone(), bytes.clone());
ImageSource::Uri(Cow::Owned(frame_uri))
}
_ => self.source.clone(),
}
}
/// Load the image from its [`Image::source`], returning the resulting [`SizedTexture`].
@ -300,7 +313,7 @@ impl<'a> Image<'a> {
/// May fail if they underlying [`Context::try_load_texture`] call fails.
pub fn load_for_size(&self, ctx: &Context, available_size: Vec2) -> TextureLoadResult {
let size_hint = self.size.hint(available_size);
self.source
self.source(ctx)
.clone()
.load(ctx, self.texture_options, size_hint)
}
@ -344,7 +357,7 @@ impl<'a> Widget for Image<'a> {
&self.image_options,
);
}
texture_load_result_response(&self.source, &tlr, response)
texture_load_result_response(&self.source(ui.ctx()), &tlr, response)
}
}
@ -769,3 +782,58 @@ pub fn paint_texture_at(
}
}
}
/// gif uris contain the uri & the frame that will be displayed
fn encode_gif_uri(uri: &str, frame_index: usize) -> String {
format!("{uri}#{frame_index}")
}
/// extracts uri and frame index
/// # Errors
/// Will return `Err` if `uri` does not match pattern {uri}-{frame_index}
pub fn decode_gif_uri(uri: &str) -> Result<(&str, usize), String> {
let (uri, index) = uri
.rsplit_once('#')
.ok_or("Failed to find index separator '#'")?;
let index: usize = index
.parse()
.map_err(|_err| format!("Failed to parse gif frame index: {index:?} is not an integer"))?;
Ok((uri, index))
}
/// checks if uri is a gif file
fn is_gif_uri(uri: &str) -> bool {
uri.ends_with(".gif") || uri.contains(".gif#")
}
/// checks if bytes are gifs
pub fn has_gif_magic_header(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
/// calculates at which frame the gif is
fn gif_frame_index(ctx: &Context, uri: &str) -> usize {
let now = ctx.input(|i| Duration::from_secs_f64(i.time));
let durations: Option<GifFrameDurations> = ctx.data(|data| data.get_temp(Id::new(uri)));
if let Some(durations) = durations {
let frames: Duration = durations.0.iter().sum();
let pos_ms = now.as_millis() % frames.as_millis().max(1);
let mut cumulative_ms = 0;
for (i, duration) in durations.0.iter().enumerate() {
cumulative_ms += duration.as_millis();
if pos_ms < cumulative_ms {
let ms_until_next_frame = cumulative_ms - pos_ms;
ctx.request_repaint_after(Duration::from_millis(ms_until_next_frame as u64));
return i;
}
}
0
} else {
0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
/// Stores the durations between each frame of a gif
pub struct GifFrameDurations(pub Arc<Vec<Duration>>);

2
crates/egui/src/widgets/image_button.rs

@ -125,6 +125,6 @@ impl<'a> Widget for ImageButton<'a> {
.rect_stroke(rect.expand2(expansion), rounding, stroke);
}
widgets::image::texture_load_result_response(self.image.source(), &tlr, response)
widgets::image::texture_load_result_response(&self.image.source(ui.ctx()), &tlr, response)
}
}

5
crates/egui/src/widgets/mod.rs

@ -27,7 +27,10 @@ pub use self::{
checkbox::Checkbox,
drag_value::DragValue,
hyperlink::{Hyperlink, Link},
image::{paint_texture_at, Image, ImageFit, ImageOptions, ImageSize, ImageSource},
image::{
decode_gif_uri, has_gif_magic_header, paint_texture_at, GifFrameDurations, Image, ImageFit,
ImageOptions, ImageSize, ImageSource,
},
image_button::ImageButton,
label::Label,
progress_bar::ProgressBar,

5
crates/egui_extras/Cargo.toml

@ -30,7 +30,7 @@ all-features = true
default = ["dep:mime_guess2"]
## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`).
all_loaders = ["file", "http", "image", "svg"]
all_loaders = ["file", "http", "image", "svg", "gif"]
## Enable [`DatePickerButton`] widget.
datepicker = ["chrono"]
@ -38,6 +38,9 @@ datepicker = ["chrono"]
## Add support for loading images from `file://` URIs.
file = ["dep:mime_guess2"]
## Support loading gif images.
gif = ["image", "image/gif"]
## Add support for loading images via HTTP.
http = ["dep:ehttp"]

9
crates/egui_extras/src/loaders.rs

@ -78,6 +78,12 @@ pub fn install_image_loaders(ctx: &egui::Context) {
log::trace!("installed ImageCrateLoader");
}
#[cfg(feature = "gif")]
if !ctx.is_loader_installed(self::gif_loader::GifLoader::ID) {
ctx.add_image_loader(std::sync::Arc::new(self::gif_loader::GifLoader::default()));
log::trace!("installed GifLoader");
}
#[cfg(feature = "svg")]
if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) {
ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default()));
@ -101,8 +107,9 @@ mod file_loader;
#[cfg(feature = "http")]
mod ehttp_loader;
#[cfg(feature = "gif")]
mod gif_loader;
#[cfg(feature = "image")]
mod image_loader;
#[cfg(feature = "svg")]
mod svg_loader;

134
crates/egui_extras/src/loaders/gif_loader.rs

@ -0,0 +1,134 @@
use egui::{
ahash::HashMap,
decode_gif_uri, has_gif_magic_header,
load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint},
mutex::Mutex,
ColorImage, GifFrameDurations, Id,
};
use image::AnimationDecoder as _;
use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration};
/// Array of Frames and the duration for how long each frame should be shown
#[derive(Debug, Clone)]
pub struct AnimatedImage {
frames: Vec<Arc<ColorImage>>,
frame_durations: GifFrameDurations,
}
impl AnimatedImage {
fn load_gif(data: &[u8]) -> Result<Self, String> {
let decoder = image::codecs::gif::GifDecoder::new(Cursor::new(data))
.map_err(|err| format!("Failed to decode gif: {err}"))?;
let mut images = vec![];
let mut durations = vec![];
for frame in decoder.into_frames() {
let frame = frame.map_err(|err| format!("Failed to decode gif: {err}"))?;
let img = frame.buffer();
let pixels = img.as_flat_samples();
let delay: Duration = frame.delay().into();
images.push(Arc::new(ColorImage::from_rgba_unmultiplied(
[img.width() as usize, img.height() as usize],
pixels.as_slice(),
)));
durations.push(delay);
}
Ok(Self {
frames: images,
frame_durations: GifFrameDurations(Arc::new(durations)),
})
}
}
impl AnimatedImage {
pub fn byte_len(&self) -> usize {
size_of::<Self>()
+ self
.frames
.iter()
.map(|image| {
image.pixels.len() * size_of::<egui::Color32>() + size_of::<Duration>()
})
.sum::<usize>()
}
/// Gets image at index
pub fn get_image(&self, index: usize) -> Arc<ColorImage> {
self.frames[index % self.frames.len()].clone()
}
}
type Entry = Result<Arc<AnimatedImage>, String>;
#[derive(Default)]
pub struct GifLoader {
cache: Mutex<HashMap<String, Entry>>,
}
impl GifLoader {
pub const ID: &'static str = egui::generate_loader_id!(GifLoader);
}
impl ImageLoader for GifLoader {
fn id(&self) -> &str {
Self::ID
}
fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult {
let (image_uri, frame_index) =
decode_gif_uri(frame_uri).map_err(|_err| LoadError::NotSupported)?;
let mut cache = self.cache.lock();
if let Some(entry) = cache.get(image_uri).cloned() {
match entry {
Ok(image) => Ok(ImagePoll::Ready {
image: image.get_image(frame_index),
}),
Err(err) => Err(LoadError::Loading(err)),
}
} else {
match ctx.try_load_bytes(image_uri) {
Ok(BytesPoll::Ready { bytes, .. }) => {
if !has_gif_magic_header(&bytes) {
return Err(LoadError::NotSupported);
}
log::trace!("started loading {image_uri:?}");
let result = AnimatedImage::load_gif(&bytes).map(Arc::new);
if let Ok(v) = &result {
ctx.data_mut(|data| {
*data.get_temp_mut_or_default(Id::new(image_uri)) =
v.frame_durations.clone();
});
}
log::trace!("finished loading {image_uri:?}");
cache.insert(image_uri.into(), result.clone());
match result {
Ok(image) => Ok(ImagePoll::Ready {
image: image.get_image(frame_index),
}),
Err(err) => Err(LoadError::Loading(err)),
}
}
Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }),
Err(err) => Err(err),
}
}
}
fn forget(&self, uri: &str) {
let _ = self.cache.lock().remove(uri);
}
fn forget_all(&self) {
self.cache.lock().clear();
}
fn byte_size(&self) -> usize {
self.cache
.lock()
.values()
.map(|v| match v {
Ok(v) => v.byte_len(),
Err(e) => e.len(),
})
.sum()
}
}

BIN
examples/images/src/ferris.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

1
examples/images/src/main.rs

@ -27,6 +27,7 @@ impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::both().show(ui, |ui| {
ui.image(egui::include_image!("ferris.gif"));
ui.add(
egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0),
);

Loading…
Cancel
Save