Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GIF support #4620

Merged
merged 23 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 index: \"{index}\""))?;
Ok((uri, index))
emilk marked this conversation as resolved.
Show resolved Hide resolved
}

/// 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
3 changes: 3 additions & 0 deletions crates/egui_extras/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ http = ["dep:ehttp"]
## ```
image = ["dep:image"]

## Support loading gif images.
gif = ["image", "image/gif"]
JustFrederik marked this conversation as resolved.
Show resolved Hide resolved

## Enable profiling with the [`puffin`](https://docs.rs/puffin) crate.
##
## Only enabled on native, because of the low resolution (1ms) of clocks in browsers.
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::{Bytes, 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: Bytes) -> Result<Self, String> {
emilk marked this conversation as resolved.
Show resolved Hide resolved
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()
}
}
2 changes: 1 addition & 1 deletion examples/images/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ eframe = { workspace = true, features = [
"default",
"__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO
] }
egui_extras = { workspace = true, features = ["default", "all_loaders"] }
egui_extras = { workspace = true, features = ["default", "all_loaders", "gif"] }
JustFrederik marked this conversation as resolved.
Show resolved Hide resolved
env_logger = { version = "0.10", default-features = false, features = [
"auto-color",
"humantime",
Expand Down
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"));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's super cool that this Just Works™️

ui.add(
egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0),
);
Expand Down
Loading