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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ecdffb913a326b6c642290a0d0ec8e8d6597291acdc07cc4c9cb4b3635d44cf9"
|
checksum = "ecdffb913a326b6c642290a0d0ec8e8d6597291acdc07cc4c9cb4b3635d44cf9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "com"
|
name = "com"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|
@ -1734,6 +1740,16 @@ dependencies = [
|
||||||
"wasi",
|
"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]]
|
[[package]]
|
||||||
name = "gimli"
|
name = "gimli"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
|
|
@ -2079,6 +2095,8 @@ checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
"zune-core",
|
"zune-core",
|
||||||
|
|
@ -4240,6 +4258,12 @@ version = "0.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc"
|
checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "0.20.1"
|
version = "0.20.1"
|
||||||
|
|
|
||||||
|
|
@ -319,8 +319,11 @@ impl Widget for Button<'_> {
|
||||||
image.show_loading_spinner,
|
image.show_loading_spinner,
|
||||||
image.image_options(),
|
image.image_options(),
|
||||||
);
|
);
|
||||||
response =
|
response = widgets::image::texture_load_result_response(
|
||||||
widgets::image::texture_load_result_response(image.source(), &tlr, response);
|
&image.source(ui.ctx()),
|
||||||
|
&tlr,
|
||||||
|
response,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if image.is_some() && galley.is_some() {
|
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 emath::{Float as _, Rot2};
|
||||||
use epaint::RectShape;
|
use epaint::RectShape;
|
||||||
|
|
@ -40,6 +40,7 @@ use crate::{
|
||||||
/// .paint_at(ui, rect);
|
/// .paint_at(ui, rect);
|
||||||
/// # });
|
/// # });
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
|
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Image<'a> {
|
pub struct Image<'a> {
|
||||||
|
|
@ -288,8 +289,20 @@ impl<'a> Image<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn source(&self) -> &ImageSource<'a> {
|
pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> {
|
||||||
&self.source
|
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`].
|
/// 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.
|
/// May fail if they underlying [`Context::try_load_texture`] call fails.
|
||||||
pub fn load_for_size(&self, ctx: &Context, available_size: Vec2) -> TextureLoadResult {
|
pub fn load_for_size(&self, ctx: &Context, available_size: Vec2) -> TextureLoadResult {
|
||||||
let size_hint = self.size.hint(available_size);
|
let size_hint = self.size.hint(available_size);
|
||||||
self.source
|
self.source(ctx)
|
||||||
.clone()
|
.clone()
|
||||||
.load(ctx, self.texture_options, size_hint)
|
.load(ctx, self.texture_options, size_hint)
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +357,7 @@ impl<'a> Widget for Image<'a> {
|
||||||
&self.image_options,
|
&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);
|
.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,
|
checkbox::Checkbox,
|
||||||
drag_value::DragValue,
|
drag_value::DragValue,
|
||||||
hyperlink::{Hyperlink, Link},
|
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,
|
image_button::ImageButton,
|
||||||
label::Label,
|
label::Label,
|
||||||
progress_bar::ProgressBar,
|
progress_bar::ProgressBar,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ all-features = true
|
||||||
default = ["dep:mime_guess2"]
|
default = ["dep:mime_guess2"]
|
||||||
|
|
||||||
## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`).
|
## 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.
|
## Enable [`DatePickerButton`] widget.
|
||||||
datepicker = ["chrono"]
|
datepicker = ["chrono"]
|
||||||
|
|
@ -38,6 +38,9 @@ datepicker = ["chrono"]
|
||||||
## Add support for loading images from `file://` URIs.
|
## Add support for loading images from `file://` URIs.
|
||||||
file = ["dep:mime_guess2"]
|
file = ["dep:mime_guess2"]
|
||||||
|
|
||||||
|
## Support loading gif images.
|
||||||
|
gif = ["image", "image/gif"]
|
||||||
|
|
||||||
## Add support for loading images via HTTP.
|
## Add support for loading images via HTTP.
|
||||||
http = ["dep:ehttp"]
|
http = ["dep:ehttp"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ pub fn install_image_loaders(ctx: &egui::Context) {
|
||||||
log::trace!("installed ImageCrateLoader");
|
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")]
|
#[cfg(feature = "svg")]
|
||||||
if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) {
|
if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) {
|
||||||
ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default()));
|
ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default()));
|
||||||
|
|
@ -101,8 +107,9 @@ mod file_loader;
|
||||||
#[cfg(feature = "http")]
|
#[cfg(feature = "http")]
|
||||||
mod ehttp_loader;
|
mod ehttp_loader;
|
||||||
|
|
||||||
|
#[cfg(feature = "gif")]
|
||||||
|
mod gif_loader;
|
||||||
#[cfg(feature = "image")]
|
#[cfg(feature = "image")]
|
||||||
mod image_loader;
|
mod image_loader;
|
||||||
|
|
||||||
#[cfg(feature = "svg")]
|
#[cfg(feature = "svg")]
|
||||||
mod svg_loader;
|
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) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
egui::ScrollArea::both().show(ui, |ui| {
|
egui::ScrollArea::both().show(ui, |ui| {
|
||||||
|
ui.image(egui::include_image!("ferris.gif"));
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0),
|
egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue