|
| 1 | +use crate::types::{FilterChainRequest, FilterStep}; |
| 2 | +use image::{imageops::FilterType, DynamicImage, ImageFormat, Rgba, RgbaImage}; |
| 3 | +use std::io::Cursor; |
| 4 | +use thiserror::Error; |
| 5 | + |
| 6 | +mod vhs; |
| 7 | + |
| 8 | +#[derive(Debug, Error)] |
| 9 | +pub enum FilterError { |
| 10 | + #[error("unsupported image data url")] |
| 11 | + UnsupportedDataUrl, |
| 12 | + #[error(transparent)] |
| 13 | + Image(#[from] image::ImageError), |
| 14 | +} |
| 15 | + |
| 16 | +fn decode_data_url_to_image(data_url: &str) -> Result<DynamicImage, FilterError> { |
| 17 | + let (header, b64) = data_url |
| 18 | + .split_once(",") |
| 19 | + .ok_or(FilterError::UnsupportedDataUrl)?; |
| 20 | + if !header.contains("base64") { |
| 21 | + return Err(FilterError::UnsupportedDataUrl); |
| 22 | + } |
| 23 | + use base64::engine::general_purpose::STANDARD as B64; |
| 24 | + use base64::Engine; |
| 25 | + let bytes = B64 |
| 26 | + .decode(b64) |
| 27 | + .map_err(|_| FilterError::UnsupportedDataUrl)?; |
| 28 | + let img = image::load_from_memory(&bytes)?; |
| 29 | + Ok(img) |
| 30 | +} |
| 31 | + |
| 32 | +fn encode_png_base64(img: &RgbaImage) -> Result<String, FilterError> { |
| 33 | + let mut buf = Cursor::new(Vec::new()); |
| 34 | + DynamicImage::ImageRgba8(img.clone()).write_to(&mut buf, ImageFormat::Png)?; |
| 35 | + use base64::engine::general_purpose::STANDARD as B64; |
| 36 | + use base64::Engine; |
| 37 | + let b64 = B64.encode(buf.into_inner()); |
| 38 | + Ok(format!("data:image/png;base64,{}", b64)) |
| 39 | +} |
| 40 | + |
| 41 | +#[allow(dead_code)] |
| 42 | +fn resize_exact_rgba(img: &DynamicImage, w: u32, h: u32) -> RgbaImage { |
| 43 | + img.resize_exact(w, h, FilterType::Nearest).to_rgba8() |
| 44 | +} |
| 45 | + |
| 46 | +fn upscale_center_to(img: &RgbaImage, display_size: u32) -> RgbaImage { |
| 47 | + let max_dim = display_size.max(1); |
| 48 | + let factor_w = (max_dim / img.width()).max(1); |
| 49 | + let factor_h = (max_dim / img.height()).max(1); |
| 50 | + let factor = factor_w.min(factor_h); |
| 51 | + let scaled = image::imageops::resize( |
| 52 | + img, |
| 53 | + img.width() * factor, |
| 54 | + img.height() * factor, |
| 55 | + FilterType::Nearest, |
| 56 | + ); |
| 57 | + let mut canvas: RgbaImage = image::ImageBuffer::from_pixel(max_dim, max_dim, Rgba([0, 0, 0, 0])); |
| 58 | + let off_x = (max_dim - scaled.width()) / 2; |
| 59 | + let off_y = (max_dim - scaled.height()) / 2; |
| 60 | + image::imageops::overlay(&mut canvas, &scaled, off_x.into(), off_y.into()); |
| 61 | + canvas |
| 62 | +} |
| 63 | + |
| 64 | +pub trait Filter { |
| 65 | + fn name(&self) -> &'static str; |
| 66 | + fn apply(&self, img: &mut RgbaImage, amount: f32); |
| 67 | +} |
| 68 | + |
| 69 | +struct IdentityFilter; |
| 70 | +impl Filter for IdentityFilter { |
| 71 | + fn name(&self) -> &'static str { "Identity" } |
| 72 | + fn apply(&self, _img: &mut RgbaImage, _amount: f32) {} |
| 73 | +} |
| 74 | + |
| 75 | +struct BrightnessFilter; |
| 76 | +impl Filter for BrightnessFilter { |
| 77 | + fn name(&self) -> &'static str { "Brightness" } |
| 78 | + fn apply(&self, img: &mut RgbaImage, amount: f32) { |
| 79 | + let centered = (amount.clamp(0.0, 1.0) - 0.5) * 2.0; // -1..1 |
| 80 | + let delta = (centered * 255.0) as i32; |
| 81 | + for p in img.pixels_mut() { |
| 82 | + let [r, g, b, a] = p.0; |
| 83 | + let nr = (r as i32 + delta).clamp(0, 255) as u8; |
| 84 | + let ng = (g as i32 + delta).clamp(0, 255) as u8; |
| 85 | + let nb = (b as i32 + delta).clamp(0, 255) as u8; |
| 86 | + *p = Rgba([nr, ng, nb, a]); |
| 87 | + } |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +struct ContrastFilter; |
| 92 | +impl Filter for ContrastFilter { |
| 93 | + fn name(&self) -> &'static str { "Contrast" } |
| 94 | + fn apply(&self, img: &mut RgbaImage, amount: f32) { |
| 95 | + let scale = 2.0_f32.powf((amount.clamp(0.0, 1.0) - 0.5) * 2.0); |
| 96 | + for p in img.pixels_mut() { |
| 97 | + let [r, g, b, a] = p.0; |
| 98 | + let fr = ((r as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0; |
| 99 | + let fg = ((g as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0; |
| 100 | + let fb = ((b as f32 / 255.0 - 0.5) * scale + 0.5) * 255.0; |
| 101 | + *p = Rgba([ |
| 102 | + fr.clamp(0.0, 255.0) as u8, |
| 103 | + fg.clamp(0.0, 255.0) as u8, |
| 104 | + fb.clamp(0.0, 255.0) as u8, |
| 105 | + a, |
| 106 | + ]); |
| 107 | + } |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +fn get_filter_by_name(name: &str) -> Option<Box<dyn Filter + Send + Sync>> { |
| 112 | + match name { |
| 113 | + "Identity" => Some(Box::new(IdentityFilter)), |
| 114 | + "Brightness" => Some(Box::new(BrightnessFilter)), |
| 115 | + "Contrast" => Some(Box::new(ContrastFilter)), |
| 116 | + "VHS" => Some(Box::new(vhs::VhsFilter)), |
| 117 | + _ => None, |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +fn apply_filter_chain(mut img: RgbaImage, steps: &[FilterStep]) -> RgbaImage { |
| 122 | + for step in steps.iter() { |
| 123 | + if !step.enabled { continue; } |
| 124 | + if let Some(f) = get_filter_by_name(step.name.as_str()) { |
| 125 | + let amt = step.amount.clamp(0.0, 1.0); |
| 126 | + let mut_ref: &mut RgbaImage = &mut img; |
| 127 | + f.apply(mut_ref, amt); |
| 128 | + } |
| 129 | + } |
| 130 | + img |
| 131 | +} |
| 132 | + |
| 133 | +pub fn render_filters_preview_png(req: FilterChainRequest) -> Result<String, FilterError> { |
| 134 | + let img = decode_data_url_to_image(&req.image_data_url)?; |
| 135 | + let mut frame = img.to_rgba8(); |
| 136 | + frame = apply_filter_chain(frame, &req.steps); |
| 137 | + let target = req.display_size.unwrap_or(560); |
| 138 | + let up = upscale_center_to(&frame, target); |
| 139 | + Ok(encode_png_base64(&up)?) |
| 140 | +} |
| 141 | + |
| 142 | + |
0 commit comments