Gif support (#4620)
* Previous PR: #3951 * Closes #4489 --------- Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
parent
902b4d960d
commit
52a8e11764
24
Cargo.lock
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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>>);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 not shown.
|
After Width: | Height: | Size: 602 KiB |
|
|
@ -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…
Reference in New Issue