images.rs (14125B)
1 use egui::{pos2, Color32, ColorImage, Rect, Sense, SizeHint}; 2 use image::codecs::gif::GifDecoder; 3 use image::imageops::FilterType; 4 use image::AnimationDecoder; 5 use image::DynamicImage; 6 use image::FlatSamples; 7 use image::Frame; 8 use notedeck::Animation; 9 use notedeck::ImageFrame; 10 use notedeck::MediaCache; 11 use notedeck::MediaCacheType; 12 use notedeck::Result; 13 use notedeck::TextureFrame; 14 use notedeck::TexturedImage; 15 use poll_promise::Promise; 16 use std::collections::VecDeque; 17 use std::io::Cursor; 18 use std::path; 19 use std::path::PathBuf; 20 use std::sync::mpsc; 21 use std::sync::mpsc::SyncSender; 22 use std::thread; 23 use std::time::Duration; 24 use tokio::fs; 25 26 // NOTE(jb55): chatgpt wrote this because I was too dumb to 27 pub fn aspect_fill( 28 ui: &mut egui::Ui, 29 sense: Sense, 30 texture_id: egui::TextureId, 31 aspect_ratio: f32, 32 ) -> egui::Response { 33 let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout 34 let frame_ratio = frame.width() / frame.height(); 35 36 let (width, height) = if frame_ratio > aspect_ratio { 37 // Frame is wider than the content 38 (frame.width(), frame.width() / aspect_ratio) 39 } else { 40 // Frame is taller than the content 41 (frame.height() * aspect_ratio, frame.height()) 42 }; 43 44 let content_rect = Rect::from_min_size( 45 frame.min 46 + egui::vec2( 47 (frame.width() - width) / 2.0, 48 (frame.height() - height) / 2.0, 49 ), 50 egui::vec2(width, height), 51 ); 52 53 // Set the clipping rectangle to the frame 54 //let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle 55 //ui.set_clip_rect(frame); 56 57 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 58 59 let (response, painter) = ui.allocate_painter(ui.available_size(), sense); 60 61 // Draw the texture within the calculated rect, potentially clipping it 62 painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill()); 63 painter.image(texture_id, content_rect, uv, Color32::WHITE); 64 65 // Restore the original clipping rectangle 66 //ui.set_clip_rect(clip_rect); 67 response 68 } 69 70 pub fn round_image(image: &mut ColorImage) { 71 #[cfg(feature = "profiling")] 72 puffin::profile_function!(); 73 74 // The radius to the edge of of the avatar circle 75 let edge_radius = image.size[0] as f32 / 2.0; 76 let edge_radius_squared = edge_radius * edge_radius; 77 78 for (pixnum, pixel) in image.pixels.iter_mut().enumerate() { 79 // y coordinate 80 let uy = pixnum / image.size[0]; 81 let y = uy as f32; 82 let y_offset = edge_radius - y; 83 84 // x coordinate 85 let ux = pixnum % image.size[0]; 86 let x = ux as f32; 87 let x_offset = edge_radius - x; 88 89 // The radius to this pixel (may be inside or outside the circle) 90 let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset; 91 92 // If inside of the avatar circle 93 if pixel_radius_squared <= edge_radius_squared { 94 // squareroot to find how many pixels we are from the edge 95 let pixel_radius: f32 = pixel_radius_squared.sqrt(); 96 let distance = edge_radius - pixel_radius; 97 98 // If we are within 1 pixel of the edge, we should fade, to 99 // antialias the edge of the circle. 1 pixel from the edge should 100 // be 100% of the original color, and right on the edge should be 101 // 0% of the original color. 102 if distance <= 1.0 { 103 *pixel = Color32::from_rgba_premultiplied( 104 (pixel.r() as f32 * distance) as u8, 105 (pixel.g() as f32 * distance) as u8, 106 (pixel.b() as f32 * distance) as u8, 107 (pixel.a() as f32 * distance) as u8, 108 ); 109 } 110 } else { 111 // Outside of the avatar circle 112 *pixel = Color32::TRANSPARENT; 113 } 114 } 115 } 116 117 fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage { 118 #[cfg(feature = "profiling")] 119 puffin::profile_function!(); 120 121 match imgtyp { 122 ImageType::Content(w, h) => { 123 let image = image.resize(w, h, FilterType::CatmullRom); // DynamicImage 124 let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) 125 let color_image = ColorImage::from_rgba_unmultiplied( 126 [ 127 image_buffer.width() as usize, 128 image_buffer.height() as usize, 129 ], 130 image_buffer.as_flat_samples().as_slice(), 131 ); 132 color_image 133 } 134 ImageType::Profile(size) => { 135 // Crop square 136 let smaller = image.width().min(image.height()); 137 138 if image.width() > smaller { 139 let excess = image.width() - smaller; 140 image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height()); 141 } else if image.height() > smaller { 142 let excess = image.height() - smaller; 143 image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess); 144 } 145 let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage 146 let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) 147 let mut color_image = ColorImage::from_rgba_unmultiplied( 148 [ 149 image_buffer.width() as usize, 150 image_buffer.height() as usize, 151 ], 152 image_buffer.as_flat_samples().as_slice(), 153 ); 154 round_image(&mut color_image); 155 color_image 156 } 157 } 158 } 159 160 fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result<ColorImage> { 161 #[cfg(feature = "profiling")] 162 puffin::profile_function!(); 163 164 let content_type = response.content_type().unwrap_or_default(); 165 let size_hint = match imgtyp { 166 ImageType::Profile(size) => SizeHint::Size(size, size), 167 ImageType::Content(w, h) => SizeHint::Size(w, h), 168 }; 169 170 if content_type.starts_with("image/svg") { 171 #[cfg(feature = "profiling")] 172 puffin::profile_scope!("load_svg"); 173 174 let mut color_image = 175 egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?; 176 round_image(&mut color_image); 177 Ok(color_image) 178 } else if content_type.starts_with("image/") { 179 #[cfg(feature = "profiling")] 180 puffin::profile_scope!("load_from_memory"); 181 let dyn_image = image::load_from_memory(&response.bytes)?; 182 Ok(process_pfp_bitmap(imgtyp, dyn_image)) 183 } else { 184 Err(format!("Expected image, found content-type {:?}", content_type).into()) 185 } 186 } 187 188 fn fetch_img_from_disk( 189 ctx: &egui::Context, 190 url: &str, 191 path: &path::Path, 192 cache_type: MediaCacheType, 193 ) -> Promise<Result<TexturedImage>> { 194 let ctx = ctx.clone(); 195 let url = url.to_owned(); 196 let path = path.to_owned(); 197 Promise::spawn_async(async move { 198 match cache_type { 199 MediaCacheType::Image => { 200 let data = fs::read(path).await?; 201 let image_buffer = 202 image::load_from_memory(&data).map_err(notedeck::Error::Image)?; 203 204 let img = buffer_to_color_image( 205 image_buffer.as_flat_samples_u8(), 206 image_buffer.width(), 207 image_buffer.height(), 208 ); 209 Ok(TexturedImage::Static(ctx.load_texture( 210 &url, 211 img, 212 Default::default(), 213 ))) 214 } 215 MediaCacheType::Gif => { 216 let gif_bytes = fs::read(path.clone()).await?; // Read entire file into a Vec<u8> 217 generate_gif(ctx, url, &path, gif_bytes, false, |i| { 218 buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height()) 219 }) 220 } 221 } 222 }) 223 } 224 225 fn generate_gif( 226 ctx: egui::Context, 227 url: String, 228 path: &path::Path, 229 data: Vec<u8>, 230 write_to_disk: bool, 231 process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static, 232 ) -> Result<TexturedImage> { 233 let decoder = { 234 let reader = Cursor::new(data.as_slice()); 235 GifDecoder::new(reader)? 236 }; 237 let (tex_input, tex_output) = mpsc::sync_channel(4); 238 let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk { 239 let (inp, out) = mpsc::sync_channel(4); 240 (Some(inp), Some(out)) 241 } else { 242 (None, None) 243 }; 244 245 let mut frames: VecDeque<Frame> = decoder 246 .into_frames() 247 .collect::<std::result::Result<VecDeque<_>, image::ImageError>>() 248 .map_err(|e| notedeck::Error::Generic(e.to_string()))?; 249 250 let first_frame = frames.pop_front().map(|frame| { 251 generate_animation_frame( 252 &ctx, 253 &url, 254 0, 255 frame, 256 maybe_encoder_input.as_ref(), 257 process_to_egui, 258 ) 259 }); 260 261 let cur_url = url.clone(); 262 thread::spawn(move || { 263 for (index, frame) in frames.into_iter().enumerate() { 264 let texture_frame = generate_animation_frame( 265 &ctx, 266 &cur_url, 267 index, 268 frame, 269 maybe_encoder_input.as_ref(), 270 process_to_egui, 271 ); 272 273 if tex_input.send(texture_frame).is_err() { 274 tracing::error!("AnimationTextureFrame mpsc stopped abruptly"); 275 break; 276 } 277 } 278 }); 279 280 if let Some(encoder_output) = maybe_encoder_output { 281 let path = path.to_owned(); 282 283 thread::spawn(move || { 284 let mut imgs = Vec::new(); 285 while let Ok(img) = encoder_output.recv() { 286 imgs.push(img); 287 } 288 289 if let Err(e) = MediaCache::write_gif(&path, &url, imgs) { 290 tracing::error!("Could not write gif to disk: {e}"); 291 } 292 }); 293 } 294 295 first_frame.map_or_else( 296 || { 297 Err(notedeck::Error::Generic( 298 "first frame not found for gif".to_owned(), 299 )) 300 }, 301 |first_frame| { 302 Ok(TexturedImage::Animated(Animation { 303 other_frames: Default::default(), 304 receiver: Some(tex_output), 305 first_frame, 306 })) 307 }, 308 ) 309 } 310 311 fn generate_animation_frame( 312 ctx: &egui::Context, 313 url: &str, 314 index: usize, 315 frame: image::Frame, 316 maybe_encoder_input: Option<&SyncSender<ImageFrame>>, 317 process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static, 318 ) -> TextureFrame { 319 let delay = Duration::from(frame.delay()); 320 let img = DynamicImage::ImageRgba8(frame.into_buffer()); 321 let color_img = process_to_egui(img); 322 323 if let Some(sender) = maybe_encoder_input { 324 if let Err(e) = sender.send(ImageFrame { 325 delay, 326 image: color_img.clone(), 327 }) { 328 tracing::error!("ImageFrame mpsc unexpectedly closed: {e}"); 329 } 330 } 331 332 TextureFrame { 333 delay, 334 texture: ctx.load_texture(format!("{}{}", url, index), color_img, Default::default()), 335 } 336 } 337 338 fn buffer_to_color_image( 339 samples: Option<FlatSamples<&[u8]>>, 340 width: u32, 341 height: u32, 342 ) -> ColorImage { 343 // TODO(jb55): remove unwrap here 344 let flat_samples = samples.unwrap(); 345 ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice()) 346 } 347 348 pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>> { 349 std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string())) 350 } 351 352 /// Controls type-specific handling 353 #[derive(Debug, Clone, Copy)] 354 pub enum ImageType { 355 /// Profile Image (size) 356 Profile(u32), 357 /// Content Image (width, height) 358 Content(u32, u32), 359 } 360 361 pub fn fetch_img( 362 img_cache: &MediaCache, 363 ctx: &egui::Context, 364 url: &str, 365 imgtyp: ImageType, 366 cache_type: MediaCacheType, 367 ) -> Promise<Result<TexturedImage>> { 368 let key = MediaCache::key(url); 369 let path = img_cache.cache_dir.join(key); 370 371 if path.exists() { 372 fetch_img_from_disk(ctx, url, &path, cache_type) 373 } else { 374 fetch_img_from_net(&img_cache.cache_dir, ctx, url, imgtyp, cache_type) 375 } 376 377 // TODO: fetch image from local cache 378 } 379 380 fn fetch_img_from_net( 381 cache_path: &path::Path, 382 ctx: &egui::Context, 383 url: &str, 384 imgtyp: ImageType, 385 cache_type: MediaCacheType, 386 ) -> Promise<Result<TexturedImage>> { 387 let (sender, promise) = Promise::new(); 388 let request = ehttp::Request::get(url); 389 let ctx = ctx.clone(); 390 let cloned_url = url.to_owned(); 391 let cache_path = cache_path.to_owned(); 392 ehttp::fetch(request, move |response| { 393 let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| { 394 match cache_type { 395 MediaCacheType::Image => { 396 let img = parse_img_response(resp, imgtyp); 397 img.map(|img| { 398 let texture_handle = 399 ctx.load_texture(&cloned_url, img.clone(), Default::default()); 400 401 // write to disk 402 std::thread::spawn(move || { 403 MediaCache::write(&cache_path, &cloned_url, img) 404 }); 405 406 TexturedImage::Static(texture_handle) 407 }) 408 } 409 MediaCacheType::Gif => { 410 let gif_bytes = resp.bytes; 411 generate_gif( 412 ctx.clone(), 413 cloned_url, 414 &cache_path, 415 gif_bytes, 416 true, 417 move |img| process_pfp_bitmap(imgtyp, img), 418 ) 419 } 420 } 421 }); 422 423 sender.send(handle); // send the results back to the UI thread. 424 ctx.request_repaint(); 425 }); 426 427 promise 428 }