diff --git a/Cargo.lock b/Cargo.lock index 9b15265c278..f6b05ed1439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2202,12 +2202,23 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "num-traits", "png", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "images" version = "0.1.0" @@ -3165,6 +3176,12 @@ dependencies = [ "puffin_http", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.30.0" diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 4cdfc5bf749..7380eaac26e 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, sync::Arc, time::Duration}; +use std::{borrow::Cow, slice::Iter, sync::Arc, time::Duration}; use emath::{Float as _, Rot2}; use epaint::RectShape; @@ -286,12 +286,12 @@ impl<'a> Image<'a> { /// Returns the URI of the image. /// - /// For GIFs, returns the URI without the frame number. + /// For animated images, returns the URI without the frame number. #[inline] pub fn uri(&self) -> Option<&str> { let uri = self.source.uri()?; - if let Ok((gif_uri, _index)) = decode_gif_uri(uri) { + if let Ok((gif_uri, _index)) = decode_animated_image_uri(uri) { Some(gif_uri) } else { Some(uri) @@ -306,13 +306,15 @@ impl<'a> Image<'a> { #[inline] 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(uri) if is_animated_image_uri(uri) => { + let frame_uri = + encode_animated_image_uri(uri, animated_image_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)); + ImageSource::Bytes { uri, bytes } if are_animated_image_bytes(bytes) => { + let frame_uri = + encode_animated_image_uri(uri, animated_image_frame_index(ctx, uri)); ctx.include_bytes(uri.clone(), bytes.clone()); ImageSource::Uri(Cow::Owned(frame_uri)) } @@ -796,57 +798,90 @@ 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 { +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +/// Stores the durations between each frame of an animated image +pub struct FrameDurations(Arc>); + +impl FrameDurations { + pub fn new(durations: Vec) -> Self { + Self(Arc::new(durations)) + } + + pub fn all(&self) -> Iter<'_, Duration> { + self.0.iter() + } +} + +/// Animated image uris contain the uri & the frame that will be displayed +fn encode_animated_image_uri(uri: &str, frame_index: usize) -> String { format!("{uri}#{frame_index}") } -/// extracts uri and 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> { +pub fn decode_animated_image_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"))?; + let index: usize = index.parse().map_err(|_err| { + format!("Failed to parse animated image 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 animated image is +fn animated_image_frame_index(ctx: &Context, uri: &str) -> usize { + let now = ctx.input(|input| Duration::from_secs_f64(input.time)); -/// 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 = ctx.data(|data| data.get_temp(Id::new(uri))); - let durations: Option = ctx.data(|data| data.get_temp(Id::new(uri))); if let Some(durations) = durations { - let frames: Duration = durations.0.iter().sum(); + let frames: Duration = durations.all().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() { + + for (index, duration) in durations.all().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; + return index; } } + 0 } else { 0 } } -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] -/// Stores the durations between each frame of a gif -pub struct GifFrameDurations(pub Arc>); +/// 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") +} + +/// Checks if uri is a webp file +fn is_webp_uri(uri: &str) -> bool { + uri.ends_with(".webp") || uri.contains(".webp#") +} + +/// Checks if bytes are webp +pub fn has_webp_header(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" +} + +fn is_animated_image_uri(uri: &str) -> bool { + is_gif_uri(uri) || is_webp_uri(uri) +} + +fn are_animated_image_bytes(bytes: &[u8]) -> bool { + has_gif_magic_header(bytes) || has_webp_header(bytes) +} diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 78e095aefdb..a4a40ec66f1 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -28,8 +28,8 @@ pub use self::{ drag_value::DragValue, hyperlink::{Hyperlink, Link}, image::{ - decode_gif_uri, has_gif_magic_header, paint_texture_at, GifFrameDurations, Image, ImageFit, - ImageOptions, ImageSize, ImageSource, + decode_animated_image_uri, has_gif_magic_header, has_webp_header, paint_texture_at, + FrameDurations, Image, ImageFit, ImageOptions, ImageSize, ImageSource, }, image_button::ImageButton, label::Label, diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 41fbcf0a462..89465f6d130 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -31,7 +31,7 @@ rustdoc-args = ["--generate-link-to-definition"] default = ["dep:mime_guess2"] ## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). -all_loaders = ["file", "http", "image", "svg", "gif"] +all_loaders = ["file", "http", "image", "svg", "gif", "webp"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] @@ -42,6 +42,9 @@ file = ["dep:mime_guess2"] ## Support loading gif images. gif = ["image", "image/gif"] +## Support loading webp images. +webp = ["image", "image/webp"] + ## Add support for loading images via HTTP. http = ["dep:ehttp"] diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index 02683e442e7..03b1abfc9f2 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -84,6 +84,12 @@ pub fn install_image_loaders(ctx: &egui::Context) { log::trace!("installed GifLoader"); } + #[cfg(feature = "webp")] + if !ctx.is_loader_installed(self::webp_loader::WebPLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::webp_loader::WebPLoader::default())); + log::trace!("installed WebPLoader"); + } + #[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())); @@ -113,3 +119,5 @@ mod gif_loader; mod image_loader; #[cfg(feature = "svg")] mod svg_loader; +#[cfg(feature = "webp")] +mod webp_loader; diff --git a/crates/egui_extras/src/loaders/gif_loader.rs b/crates/egui_extras/src/loaders/gif_loader.rs index 1c20135150c..a92cbc33e41 100644 --- a/crates/egui_extras/src/loaders/gif_loader.rs +++ b/crates/egui_extras/src/loaders/gif_loader.rs @@ -1,9 +1,9 @@ use ahash::HashMap; use egui::{ - decode_gif_uri, has_gif_magic_header, + decode_animated_image_uri, has_gif_magic_header, load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, mutex::Mutex, - ColorImage, GifFrameDurations, Id, + ColorImage, FrameDurations, Id, }; use image::AnimationDecoder as _; use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; @@ -12,7 +12,7 @@ use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; #[derive(Debug, Clone)] pub struct AnimatedImage { frames: Vec>, - frame_durations: GifFrameDurations, + frame_durations: FrameDurations, } impl AnimatedImage { @@ -35,7 +35,7 @@ impl AnimatedImage { } Ok(Self { frames: images, - frame_durations: GifFrameDurations(Arc::new(durations)), + frame_durations: FrameDurations::new(durations), }) } } @@ -75,7 +75,7 @@ impl ImageLoader for GifLoader { 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)?; + decode_animated_image_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 { diff --git a/crates/egui_extras/src/loaders/webp_loader.rs b/crates/egui_extras/src/loaders/webp_loader.rs new file mode 100644 index 00000000000..bb042093b17 --- /dev/null +++ b/crates/egui_extras/src/loaders/webp_loader.rs @@ -0,0 +1,186 @@ +use ahash::HashMap; +use egui::{ + decode_animated_image_uri, has_webp_header, + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, FrameDurations, Id, +}; +use image::{codecs::webp::WebPDecoder, AnimationDecoder as _, ImageDecoder, Rgba}; +use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; + +#[derive(Clone)] +enum WebP { + Static(Arc), + Animated(AnimatedImage), +} + +impl WebP { + fn load(data: &[u8]) -> Result { + let mut decoder = WebPDecoder::new(Cursor::new(data)) + .map_err(|error| format!("WebP decode failure ({error})"))?; + + if decoder.has_animation() { + decoder + .set_background_color(Rgba([0, 0, 0, 0])) + .map_err(|error| { + format!("Failure to set default background color for animated WebP ({error})") + })?; + + let mut images = vec![]; + let mut durations = vec![]; + + for frame in decoder.into_frames() { + let frame = + frame.map_err(|error| format!("WebP frame decode failure ({error})"))?; + let image = frame.buffer(); + let pixels = image.as_flat_samples(); + + images.push(Arc::new(ColorImage::from_rgba_unmultiplied( + [image.width() as usize, image.height() as usize], + pixels.as_slice(), + ))); + + let delay: Duration = frame.delay().into(); + durations.push(delay); + } + Ok(Self::Animated(AnimatedImage { + frames: images, + frame_durations: FrameDurations::new(durations), + })) + } else { + let (width, height) = decoder.dimensions(); + let size = decoder.total_bytes() as usize; + + let mut data = vec![0; size]; + decoder + .read_image(&mut data) + .map_err(|error| format!("WebP image read failure ({error})"))?; + + let image = + ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &data); + + Ok(Self::Static(Arc::new(image))) + } + } + + fn get_image(&self, frame_index: usize) -> Arc { + match self { + Self::Static(image) => image.clone(), + Self::Animated(animation) => animation.get_image_by_index(frame_index), + } + } + + pub fn byte_len(&self) -> usize { + size_of::() + + match self { + Self::Static(image) => image.pixels.len() * size_of::(), + Self::Animated(animation) => animation.byte_len(), + } + } +} + +#[derive(Debug, Clone)] +pub struct AnimatedImage { + frames: Vec>, + frame_durations: FrameDurations, +} + +impl AnimatedImage { + pub fn byte_len(&self) -> usize { + size_of::() + + self + .frames + .iter() + .map(|image| { + image.pixels.len() * size_of::() + size_of::() + }) + .sum::() + } + + pub fn get_image_by_index(&self, index: usize) -> Arc { + self.frames[index % self.frames.len()].clone() + } +} + +type Entry = Result; + +#[derive(Default)] +pub struct WebPLoader { + cache: Mutex>, +} + +impl WebPLoader { + pub const ID: &'static str = egui::generate_loader_id!(WebPLoader); +} + +impl ImageLoader for WebPLoader { + fn id(&self) -> &str { + Self::ID + } + + fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult { + let (image_uri, frame_index) = + decode_animated_image_uri(frame_uri).map_err(|_error| 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(error) => Err(LoadError::Loading(error)), + } + } else { + match ctx.try_load_bytes(image_uri) { + Ok(BytesPoll::Ready { bytes, .. }) => { + if !has_webp_header(&bytes) { + return Err(LoadError::NotSupported); + } + + log::trace!("started loading {image_uri:?}"); + + let result = WebP::load(&bytes); + + if let Ok(WebP::Animated(animated_image)) = &result { + ctx.data_mut(|data| { + *data.get_temp_mut_or_default(Id::new(image_uri)) = + animated_image.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(error) => Err(LoadError::Loading(error)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(error) => Err(error), + } + } + } + + 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(|entry| match entry { + Ok(entry_value) => entry_value.byte_len(), + Err(error) => error.len(), + }) + .sum() + } +} diff --git a/examples/images/screenshot.png b/examples/images/screenshot.png index 7d81312aafd..833b6565b2f 100644 --- a/examples/images/screenshot.png +++ b/examples/images/screenshot.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12eb9463cda6c2b1a160f085324f1afdfc5ced9ff0857df117030d8771259e5e -size 303453 +oid sha256:a836741d52e1972b2047cefaabf59f601637d430d4b41bf6407ebda4f7931dac +size 273450 diff --git a/examples/images/src/cat.webp b/examples/images/src/cat.webp new file mode 100644 index 00000000000..a0c41da8968 Binary files /dev/null and b/examples/images/src/cat.webp differ diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs index f2ce5729a22..a8373774a0e 100644 --- a/examples/images/src/main.rs +++ b/examples/images/src/main.rs @@ -6,7 +6,7 @@ use eframe::egui; fn main() -> eframe::Result { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_inner_size([400.0, 800.0]), + viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 880.0]), ..Default::default() }; eframe::run_native( @@ -27,11 +27,16 @@ 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), - ); - ui.image(egui::include_image!("ferris.svg")); + ui.image(egui::include_image!("cat.webp")) + .on_hover_text_at_pointer("WebP"); + ui.image(egui::include_image!("ferris.gif")) + .on_hover_text_at_pointer("Gif"); + ui.image(egui::include_image!("ferris.svg")) + .on_hover_text_at_pointer("Svg"); + + let url = "https://picsum.photos/seed/1.759706314/1024"; + ui.add(egui::Image::new(url).rounding(10.0)) + .on_hover_text_at_pointer(url); }); }); }