notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }