use std::path::{Path, PathBuf};
use base64::{engine::general_purpose, Engine as _};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MediaError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid media content: empty or corrupted")]
InvalidContent,
#[error("Image processing error: {0}")]
Image(String),
}
pub async fn download_media(
url: &str,
filename: Option<&str>,
save_dir: &PathBuf,
) -> Result<PathBuf, MediaError> {
tokio::fs::create_dir_all(save_dir).await?;
let save_filename = match filename {
Some(s) => s.to_string(),
None => uuid::Uuid::new_v4().to_string(),
};
let save_path = save_dir.join(save_filename);
let client = reqwest::Client::new();
let response = client.get(url).send().await?;
let bytes = response.bytes().await?;
if bytes.is_empty() {
return Err(MediaError::InvalidContent);
}
tokio::fs::write(&save_path, &bytes).await?;
Ok(save_path)
}
#[derive(Debug)]
pub struct EncodedImage {
pub data_url: String,
pub compression: CompressedImage,
}
pub async fn download_and_encode_base64(
url: &str,
content_type: &str,
max_image_dim: u32,
) -> Result<EncodedImage, MediaError> {
let client = reqwest::Client::new();
let bytes = client.get(url).send().await?.bytes().await?;
if bytes.is_empty() {
return Err(MediaError::InvalidContent);
}
encode_image_bytes(&bytes, content_type, max_image_dim).await
}
pub async fn encode_file_base64(
path: &Path,
max_image_dim: u32,
) -> Result<EncodedImage, MediaError> {
let bytes = tokio::fs::read(path).await?;
if bytes.is_empty() {
return Err(MediaError::InvalidContent);
}
let mime_type = mime_from_extension(path);
encode_image_bytes(&bytes, mime_type, max_image_dim).await
}
async fn encode_image_bytes(
bytes: &[u8],
mime: &str,
max_image_dim: u32,
) -> Result<EncodedImage, MediaError> {
let mut compression = if mime.starts_with("image/") {
let owned = bytes.to_vec();
let mime = mime.to_string();
tokio::task::spawn_blocking(move || compress_image_bytes(&owned, &mime, max_image_dim))
.await
.map_err(|e| MediaError::Image(format!("compression task failed: {}", e)))??
} else {
CompressedImage {
bytes: bytes.to_vec(),
mime: mime.to_string(),
orig_dims: (0, 0),
new_dims: (0, 0),
kept_original: true,
}
};
let data_url = format!(
"data:{};base64,{}",
compression.mime,
general_purpose::STANDARD.encode(&compression.bytes)
);
compression.bytes = Vec::new();
Ok(EncodedImage {
data_url,
compression,
})
}
fn mime_from_extension(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "application/octet-stream",
}
}
#[derive(Debug)]
pub struct CompressedImage {
pub bytes: Vec<u8>,
pub mime: String,
pub orig_dims: (u32, u32),
pub new_dims: (u32, u32),
pub kept_original: bool,
}
const MAX_DECODE_DIMENSION: u32 = 16384;
const MAX_DECODE_ALLOC: u64 = 256 * 1024 * 1024;
fn compress_image_bytes(bytes: &[u8], mime: &str, max_dim: u32) -> Result<CompressedImage, MediaError> {
if max_dim == 0 || mime == "image/gif" {
return Ok(CompressedImage {
bytes: bytes.to_vec(),
mime: mime.to_string(),
orig_dims: (0, 0),
new_dims: (0, 0),
kept_original: true,
});
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DECODE_DIMENSION);
limits.max_image_height = Some(MAX_DECODE_DIMENSION);
limits.max_alloc = Some(MAX_DECODE_ALLOC);
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| MediaError::Image(format!("format detection failed: {}", e)))?;
reader.limits(limits);
let img = reader
.decode()
.map_err(|e| MediaError::Image(format!("decode failed: {}", e)))?;
let orig_dims = (img.width(), img.height());
let rgb = flatten_alpha_to_white(img);
let final_img = if orig_dims.0.max(orig_dims.1) > max_dim {
let longest = orig_dims.0.max(orig_dims.1) as f32;
let scale = max_dim as f32 / longest;
let nw = ((orig_dims.0 as f32 * scale).round() as u32).max(1);
let nh = ((orig_dims.1 as f32 * scale).round() as u32).max(1);
image::imageops::resize(&rgb, nw, nh, image::imageops::FilterType::Lanczos3)
} else {
rgb
};
let mut jpeg = Vec::new();
{
use image::ImageEncoder as _;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85);
encoder
.write_image(
final_img.as_raw(),
final_img.width(),
final_img.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|e| MediaError::Image(format!("JPEG encode failed: {}", e)))?;
}
if jpeg.len() >= bytes.len() {
return Ok(CompressedImage {
bytes: bytes.to_vec(),
mime: mime.to_string(),
orig_dims,
new_dims: orig_dims,
kept_original: true,
});
}
Ok(CompressedImage {
bytes: jpeg,
mime: "image/jpeg".to_string(),
orig_dims,
new_dims: (final_img.width(), final_img.height()),
kept_original: false,
})
}
fn flatten_alpha_to_white(img: image::DynamicImage) -> image::RgbImage {
use image::GenericImageView;
let (w, h) = img.dimensions();
let mut out = image::RgbImage::new(w, h);
for (x, y, p) in img.pixels() {
let a = p.0[3] as f32 / 255.0;
let blend = |c: u8| ((c as f32 * a) + (255.0 * (1.0 - a))).round() as u8;
out.put_pixel(x, y, image::Rgb([blend(p.0[0]), blend(p.0[1]), blend(p.0[2])]));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn noise_png(w: u32, h: u32) -> Vec<u8> {
let mut img = image::RgbImage::new(w, h);
let mut seed: u32 = 0x1234_5678;
for (_, _, p) in img.enumerate_pixels_mut() {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*p = image::Rgb([(seed >> 16) as u8, (seed >> 8) as u8, seed as u8]);
}
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
fn solid_png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([200, 30, 30]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
fn noise_gif(w: u32, h: u32) -> Vec<u8> {
let mut img = image::RgbImage::new(w, h);
let mut seed: u32 = 0xDEAD_BEEF;
for (_, _, p) in img.enumerate_pixels_mut() {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*p = image::Rgb([(seed >> 16) as u8, (seed >> 8) as u8, seed as u8]);
}
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Gif)
.unwrap();
buf.into_inner()
}
#[test]
fn big_image_is_downscaled_and_reencoded_as_jpeg() {
let orig = noise_png(2048, 2048);
let out = compress_image_bytes(&orig, "image/png", 1024).unwrap();
assert_eq!(out.mime, "image/jpeg");
assert_eq!(out.orig_dims, (2048, 2048));
assert_eq!(out.new_dims, (1024, 1024));
assert!(!out.kept_original);
assert_eq!(&out.bytes[0..2], &[0xFF, 0xD8]);
assert!(
out.bytes.len() * 10 < orig.len(),
"2048px noise PNG should shrink >10x as a 1024px JPEG: {} -> {} bytes",
orig.len(),
out.bytes.len()
);
}
#[test]
fn small_image_keeps_dimensions_still_reencodes() {
let orig = noise_png(800, 600);
let out = compress_image_bytes(&orig, "image/png", 1024).unwrap();
assert_eq!(out.new_dims, (800, 600));
assert_eq!(out.mime, "image/jpeg");
assert!(!out.kept_original);
}
#[test]
fn gif_passes_through_unchanged() {
let orig = noise_gif(32, 32);
let out = compress_image_bytes(&orig, "image/gif", 1024).unwrap();
assert!(out.kept_original);
assert_eq!(out.bytes, orig);
assert_eq!(out.mime, "image/gif");
}
#[test]
fn tiny_image_keeps_original_when_reencoding_would_grow() {
let orig = solid_png(8, 8);
let out = compress_image_bytes(&orig, "image/png", 1024).unwrap();
assert!(out.kept_original, "tiny PNG should not be replaced by a larger JPEG");
assert_eq!(out.bytes, orig);
assert_eq!(out.mime, "image/png");
}
#[test]
fn transparent_pixels_flatten_to_white() {
let mut img = image::RgbaImage::new(256, 256);
let mut seed: u32 = 0x0BAD_C0DE;
for (_, _, p) in img.enumerate_pixels_mut() {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*p = image::Rgba([(seed >> 16) as u8, (seed >> 8) as u8, seed as u8, 0]);
}
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let out = compress_image_bytes(&buf.get_ref(), "image/png", 1024).unwrap();
assert_eq!(out.mime, "image/jpeg");
assert!(!out.kept_original);
let decoded = image::load_from_memory(&out.bytes).unwrap();
use image::GenericImageView as _;
let p = decoded.get_pixel(0, 0);
assert!(
p.0[0] >= 250 && p.0[1] >= 250 && p.0[2] >= 250,
"transparent pixels should flatten to white, got {:?}",
p
);
}
#[test]
fn zero_max_dimension_disables_compression() {
let orig = noise_png(2048, 2048);
let out = compress_image_bytes(&orig, "image/png", 0).unwrap();
assert!(out.kept_original);
assert_eq!(out.bytes, orig);
assert_eq!(out.mime, "image/png");
}
#[test]
fn non_square_images_scale_by_longest_side() {
let out = compress_image_bytes(&noise_png(600, 2000), "image/png", 1024).unwrap();
assert_eq!(out.new_dims, (307, 1024));
assert_eq!(out.orig_dims, (600, 2000));
let out = compress_image_bytes(&noise_png(10000, 10), "image/png", 1024).unwrap();
assert_eq!(out.new_dims, (1024, 1));
}
#[test]
fn oversized_dimensions_are_rejected_before_decoding() {
let bomb = solid_png(20000, 10);
let result = compress_image_bytes(&bomb, "image/png", 1024);
assert!(
result.is_err(),
"images wider/taller than the decode cap must be rejected, got Ok"
);
}
}