diff --git a/example.json b/example.json index cdc3ab2..1ce53e0 100644 --- a/example.json +++ b/example.json @@ -42,6 +42,10 @@ "enable_images": true, "imgur_images": true, "litterbox_images": false, - "process_images": true + "process_images": true, + "size": 512, + "bg": true, + "bg_blur": 3, + "corner_radius": 4 } } diff --git a/jellyfin-rpc-cli/src/config.rs b/jellyfin-rpc-cli/src/config.rs index 9f26981..7fabd72 100644 --- a/jellyfin-rpc-cli/src/config.rs +++ b/jellyfin-rpc-cli/src/config.rs @@ -76,6 +76,16 @@ pub struct Images { pub litterbox_images: bool, /// Processes images by making them square and adding a blur. pub process_images: bool, + /// The size of the output square image canvas (e.g., 512 for 512x512px). + pub size: Option, + /// Whether to create a blurred background. Default: true. + pub bg: bool, + /// The blur radius for the background image as a percentage of canvas size. + /// Default: 3. + pub bg_blur: f32, + /// Corner radius as a percentage of the image size. + /// Only applied when background is disabled. Default: 4. + pub corner_radius: Option, } impl Config { @@ -164,6 +174,16 @@ pub struct ImagesBuilder { pub imgur_images: Option, pub litterbox_images: Option, pub process_images: Option, + /// The size of the output square image canvas (e.g., 512 for 512x512px). + pub size: Option, + /// Whether to create a blurred background. Default: true. + pub bg: Option, + /// The blur radius for the background image as a percentage of canvas size. + /// Default: 3. + pub bg_blur: Option, + /// Corner radius as a percentage of the image size. + /// Only applied when background is disabled. Default: 4. + pub corner_radius: Option, } /// Find urls.json in filesystem, used to store images that were already previously uploaded to imgur. @@ -368,17 +388,29 @@ impl ConfigBuilder { let imgur_images; let litterbox_images; let process_images; + let image_size; + let image_bg; + let image_bg_blur; + let image_corner_radius; if let Some(images) = self.images { enable_images = images.enable_images.unwrap_or(false); imgur_images = images.imgur_images.unwrap_or(false); litterbox_images = images.litterbox_images.unwrap_or(false); process_images = images.process_images.unwrap_or(true); + image_size = images.size; + image_bg = images.bg.unwrap_or(true); + image_bg_blur = images.bg_blur.unwrap_or(3.0); + image_corner_radius = images.corner_radius.or(Some(4.0)); } else { enable_images = false; imgur_images = false; litterbox_images = false; process_images = true; + image_size = None; + image_bg = true; + image_bg_blur = 3.0; + image_corner_radius = Some(4.0); } let url; @@ -429,6 +461,10 @@ impl ConfigBuilder { imgur_images, litterbox_images, process_images, + size: image_size, + bg: image_bg, + bg_blur: image_bg_blur, + corner_radius: image_corner_radius, }, } } diff --git a/jellyfin-rpc-cli/src/main.rs b/jellyfin-rpc-cli/src/main.rs index 44cd651..127799a 100644 --- a/jellyfin-rpc-cli/src/main.rs +++ b/jellyfin-rpc-cli/src/main.rs @@ -101,6 +101,10 @@ fn main() -> Result<(), Box> { .use_imgur(conf.images.imgur_images) .use_litterbox(conf.images.litterbox_images) .process_images(conf.images.process_images) + .image_size(conf.images.size) + .image_background(conf.images.bg) + .image_background_blur(conf.images.bg_blur) + .image_corner_radius(conf.images.corner_radius) .large_image_text(format!("Jellyfin-RPC v{}", VERSION.unwrap_or("UNKNOWN"))) .imgur_urls_file_location(args.image_urls.clone().unwrap_or(get_urls_path()?)) .litterbox_urls_file_location(args.image_urls.unwrap_or(get_urls_path()?)); diff --git a/jellyfin-rpc/src/external/image_utils.rs b/jellyfin-rpc/src/external/image_utils.rs index e4b3931..575de95 100644 --- a/jellyfin-rpc/src/external/image_utils.rs +++ b/jellyfin-rpc/src/external/image_utils.rs @@ -1,29 +1,102 @@ use image::{DynamicImage, GenericImageView, ImageFormat, imageops}; use std::io::{Cursor}; -pub fn make_square_with_blur(input_bytes: &[u8]) -> Result, image::ImageError> { +#[derive(Debug, Clone)] +pub struct ImageProcessingOptions { + pub size: Option, + pub background: bool, + pub background_blur: f32, + pub corner_radius: Option, +} + +impl Default for ImageProcessingOptions { + fn default() -> Self { + Self { + size: None, + background: true, + background_blur: 3.0, + corner_radius: Some(4.0), + } + } +} +pub fn make_square_with_blur(input_bytes: &[u8], options: &ImageProcessingOptions) -> Result, image::ImageError> { let img = image::load_from_memory(input_bytes)?; let (width, height) = img.dimensions(); - let size = width.max(height); - - let bg_buf = imageops::resize(&img, size, size, imageops::FilterType::Gaussian); - let mut bg_dyn = DynamicImage::ImageRgba8(bg_buf); - bg_dyn = DynamicImage::ImageRgba8(imageops::blur(&bg_dyn, 20.0)); + let size = options.size.unwrap_or_else(|| width.max(height)); let fg_buf = if width > height { - imageops::resize(&img, size, (size as f32 * (height as f32 / width as f32)) as u32, imageops::FilterType::Lanczos3) + let new_height = (size as f32 * (height as f32 / width as f32)) as u32; + imageops::resize(&img, size, new_height, imageops::FilterType::Lanczos3) } else { - imageops::resize(&img, (size as f32 * (width as f32 / height as f32)) as u32, size, imageops::FilterType::Lanczos3) + let new_width = (size as f32 * (width as f32 / height as f32)) as u32; + imageops::resize(&img, new_width, size, imageops::FilterType::Lanczos3) }; + let fg_dyn = DynamicImage::ImageRgba8(fg_buf); let (fg_w, fg_h) = fg_dyn.dimensions(); - let mut canvas = DynamicImage::new_rgba8(size, size); - imageops::overlay(&mut canvas, &bg_dyn, 0, 0); - imageops::overlay(&mut canvas, &fg_dyn, ((size - fg_w) / 2) as i64, ((size - fg_h) / 2) as i64); + + if options.background { + let bg_buf = imageops::resize(&img, size, size, imageops::FilterType::Gaussian); + let mut bg_dyn = DynamicImage::ImageRgba8(bg_buf); + if options.background_blur > 0.0 { + let blur_radius = (size as f32) * (options.background_blur / 100.0); + bg_dyn = DynamicImage::ImageRgba8(imageops::blur(&bg_dyn, blur_radius)); + } + imageops::overlay(&mut canvas, &bg_dyn, 0, 0); + imageops::overlay(&mut canvas, &fg_dyn, ((size - fg_w) / 2) as i64, ((size - fg_h) / 2) as i64); + } else { + let mut fg_rounded = fg_dyn; + + if let Some(radius_percent) = options.corner_radius { + let radius = ((size as f32) * (radius_percent / 100.0)) as u32; + if radius > 0 { + apply_rounded_corners(&mut fg_rounded, radius); + } + } + + imageops::overlay(&mut canvas, &fg_rounded, ((size - fg_w) / 2) as i64, ((size - fg_h) / 2) as i64); + } let mut buf = Vec::new(); canvas.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?; Ok(buf) } + +fn apply_rounded_corners(img: &mut DynamicImage, radius: u32) { + let (width, height) = img.dimensions(); + let rgba = img.as_mut_rgba8().unwrap(); + let radius_f = radius as f32; + + for y in 0..height { + for x in 0..width { + let corner_center = if x < radius && y < radius { + Some((radius - 1, radius - 1)) + } else if x >= width - radius && y < radius { + Some((width - radius, radius - 1)) + } else if x < radius && y >= height - radius { + Some((radius - 1, height - radius)) + } else if x >= width - radius && y >= height - radius { + Some((width - radius, height - radius)) + } else { + None + }; + + if let Some((cx, cy)) = corner_center { + let dx = x as f32 - cx as f32; + let dy = y as f32 - cy as f32; + let distance = (dx * dx + dy * dy).sqrt(); + + if distance > radius_f { + let pixel = rgba.get_pixel_mut(x, y); + pixel[3] = 0; + } else if distance > radius_f - 1.0 { + let alpha_factor = radius_f - distance; + let pixel = rgba.get_pixel_mut(x, y); + pixel[3] = (pixel[3] as f32 * alpha_factor).round() as u8; + } + } + } + } +} diff --git a/jellyfin-rpc/src/external/imgur.rs b/jellyfin-rpc/src/external/imgur.rs index 9870211..0878e14 100644 --- a/jellyfin-rpc/src/external/imgur.rs +++ b/jellyfin-rpc/src/external/imgur.rs @@ -99,7 +99,7 @@ fn upload(client: &Client) -> JfResult { let body = if client.process_images { use crate::external::image_utils::make_square_with_blur; - make_square_with_blur(&image_bytes)? + make_square_with_blur(&image_bytes, &client.image_processing_options)? } else { image_bytes.to_vec() }; diff --git a/jellyfin-rpc/src/external/litterbox.rs b/jellyfin-rpc/src/external/litterbox.rs index bd69f23..959a28b 100644 --- a/jellyfin-rpc/src/external/litterbox.rs +++ b/jellyfin-rpc/src/external/litterbox.rs @@ -133,7 +133,7 @@ fn upload(client: &Client) -> JfResult { let file_bytes = if client.process_images { use crate::external::image_utils::make_square_with_blur; - make_square_with_blur(&image_bytes)? + make_square_with_blur(&image_bytes, &client.image_processing_options)? } else { image_bytes.to_vec() }; diff --git a/jellyfin-rpc/src/lib.rs b/jellyfin-rpc/src/lib.rs index cfa365a..4083312 100644 --- a/jellyfin-rpc/src/lib.rs +++ b/jellyfin-rpc/src/lib.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::time::SystemTime; use url::Url; +pub use external::image_utils::ImageProcessingOptions; mod error; mod external; @@ -42,6 +43,7 @@ pub struct Client { imgur_options: ImgurOptions, litterbox_options: LitterboxOptions, process_images: bool, + image_processing_options: external::image_utils::ImageProcessingOptions, large_image_text: String, } @@ -1013,6 +1015,10 @@ pub struct ClientBuilder { litterbox_urls_file_location: String, large_image_text: String, process_images: bool, + image_size: Option, + image_background: bool, + image_background_blur: f32, + image_corner_radius: Option, } impl ClientBuilder { @@ -1032,6 +1038,9 @@ impl ClientBuilder { }), show_paused: true, process_images: true, + image_background: true, + image_background_blur: 3.0, + image_corner_radius: Some(4.0), ..Default::default() } } @@ -1266,6 +1275,34 @@ impl ClientBuilder { self } + /// Set the size of the processed image (e.g., 512 for 512x512px). + /// Default: uses original image's largest dimension. + pub fn image_size(&mut self, size: Option) -> &mut Self { + self.image_size = size; + self + } + + /// Enable or disable the blurred background for processed images. + /// Defaults to `true`. When disabled the background is transparent. + pub fn image_background(&mut self, val: bool) -> &mut Self { + self.image_background = val; + self + } + + /// Set the blur radius for the background image as a percentage of canvas size. + /// Valid range: 0-50. Defaults to `3.0`. + pub fn image_background_blur(&mut self, blur: f32) -> &mut Self { + self.image_background_blur = blur; + self + } + + /// Set the rounded corner radius as a percentage of the image size. + /// Only applied when background is disabled. Defaults to `4.0`. + pub fn image_corner_radius(&mut self, radius: Option) -> &mut Self { + self.image_corner_radius = radius; + self + } + /// Text to be displayed when hovering the large activity image in Discord /// /// Empty by default @@ -1342,6 +1379,12 @@ impl ClientBuilder { urls_location: self.litterbox_urls_file_location, }, process_images: self.process_images, + image_processing_options: external::image_utils::ImageProcessingOptions { + size: self.image_size, + background: self.image_background, + background_blur: self.image_background_blur, + corner_radius: self.image_corner_radius, + }, large_image_text: self.large_image_text, }) }