Skip to content

Commit

Permalink
Gif support (#4620)
Browse files Browse the repository at this point in the history
* Previous PR: #3951 
* Closes #4489

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
  • Loading branch information
JustFrederik and emilk authored Jun 19, 2024
1 parent 902b4d9 commit 52a8e11
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 11 deletions.
24 changes: 24 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -2079,6 +2095,8 @@ checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"gif",
"num-traits",
"png",
"zune-core",
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions crates/egui/src/widgets/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
78 changes: 73 additions & 5 deletions crates/egui/src/widgets/image.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::borrow::Cow;
use std::{borrow::Cow, sync::Arc, time::Duration};

use emath::{Float as _, Rot2};
use epaint::RectShape;
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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`].
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 changes: 1 addition & 1 deletion crates/egui/src/widgets/image_button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 4 additions & 1 deletion crates/egui/src/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion crates/egui_extras/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ 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"]

## 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"]

Expand Down
9 changes: 8 additions & 1 deletion crates/egui_extras/src/loaders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -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 changes: 134 additions & 0 deletions crates/egui_extras/src/loaders/gif_loader.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
Binary file added examples/images/src/ferris.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions examples/images/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down

0 comments on commit 52a8e11

Please sign in to comment.