mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-04 13:06:50 +00:00
257 lines
8.1 KiB
Rust
257 lines
8.1 KiB
Rust
use image::{DynamicImage, ImageFormat};
|
|
use std::borrow::Cow;
|
|
use std::io::Cursor;
|
|
use std::time::Instant;
|
|
use tracing::{debug, error, info, instrument};
|
|
|
|
use crate::{hash::Hash, ImageKind, PKAvatarError};
|
|
|
|
const MAX_DIMENSION: u32 = 4000;
|
|
|
|
pub struct ProcessOutput {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub hash: Hash,
|
|
pub format: ProcessedFormat,
|
|
pub data: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug)]
|
|
pub enum ProcessedFormat {
|
|
Webp,
|
|
Gif,
|
|
}
|
|
|
|
impl ProcessedFormat {
|
|
pub fn mime_type(&self) -> &'static str {
|
|
match self {
|
|
ProcessedFormat::Gif => "image/gif",
|
|
ProcessedFormat::Webp => "image/webp",
|
|
}
|
|
}
|
|
|
|
pub fn extension(&self) -> &'static str {
|
|
match self {
|
|
ProcessedFormat::Webp => "webp",
|
|
ProcessedFormat::Gif => "gif",
|
|
}
|
|
}
|
|
}
|
|
|
|
// Moving Vec<u8> in here since the thread needs ownership of it now, it's fine, don't need it after
|
|
pub async fn process_async(data: Vec<u8>, kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
|
tokio::task::spawn_blocking(move || process(&data, kind))
|
|
.await
|
|
.map_err(|je| PKAvatarError::InternalError(je.into()))?
|
|
}
|
|
|
|
#[instrument(skip_all)]
|
|
pub fn process(data: &[u8], kind: ImageKind) -> Result<ProcessOutput, PKAvatarError> {
|
|
let time_before = Instant::now();
|
|
let reader = reader_for(data);
|
|
match reader.format() {
|
|
Some(ImageFormat::Png | ImageFormat::WebP | ImageFormat::Jpeg | ImageFormat::Tiff) => {} // ok :)
|
|
Some(ImageFormat::Gif) => {
|
|
// animated gifs will need to be handled totally differently
|
|
// so split off processing here and come back if it's not applicable
|
|
// (non-banner gifs + 1-frame animated gifs still need to be webp'd)
|
|
if let Some(output) = process_gif(data, kind)? {
|
|
return Ok(output);
|
|
}
|
|
}
|
|
Some(other) => return Err(PKAvatarError::UnsupportedImageFormat(other)),
|
|
None => return Err(PKAvatarError::UnknownImageFormat),
|
|
}
|
|
|
|
// want to check dimensions *before* decoding so we don't accidentally end up with a memory bomb
|
|
// eg. a 16000x16000 png file is only 31kb and expands to almost a gig of memory
|
|
let (width, height) = assert_dimensions(reader.into_dimensions()?)?;
|
|
|
|
// need to make a new reader??? why can't it just use the same one. reduce duplication?
|
|
let reader = reader_for(data);
|
|
|
|
let time_after_parse = Instant::now();
|
|
|
|
// apparently `image` sometimes decodes webp images wrong/weird.
|
|
// see: https://discord.com/channels/466707357099884544/667795132971614229/1209925940835262464
|
|
// instead, for webp, we use libwebp itself to decode, as well.
|
|
// (pls no cve)
|
|
let image = if reader.format() == Some(ImageFormat::WebP) {
|
|
let webp_image = webp::Decoder::new(data).decode().ok_or_else(|| {
|
|
PKAvatarError::InternalError(anyhow::anyhow!("webp decode failed").into())
|
|
})?;
|
|
webp_image.to_image()
|
|
} else {
|
|
reader.decode().map_err(|e| {
|
|
// print the ugly error, return the nice error
|
|
error!(error = format!("{e:#?}"), "error decoding image");
|
|
PKAvatarError::ImageFormatError(e)
|
|
})?
|
|
};
|
|
|
|
let time_after_decode = Instant::now();
|
|
let image = resize(image, kind);
|
|
let time_after_resize = Instant::now();
|
|
|
|
let encoded = encode(image);
|
|
let time_after = Instant::now();
|
|
|
|
info!(
|
|
"{}: lossy size {}K (parse: {} ms, decode: {} ms, resize: {} ms, encode: {} ms)",
|
|
encoded.hash,
|
|
encoded.data.len() / 1024,
|
|
(time_after_parse - time_before).as_millis(),
|
|
(time_after_decode - time_after_parse).as_millis(),
|
|
(time_after_resize - time_after_decode).as_millis(),
|
|
(time_after - time_after_resize).as_millis(),
|
|
);
|
|
|
|
debug!(
|
|
"processed image {}: {} bytes, {}x{} -> {} bytes, {}x{}",
|
|
encoded.hash,
|
|
data.len(),
|
|
width,
|
|
height,
|
|
encoded.data.len(),
|
|
encoded.width,
|
|
encoded.height
|
|
);
|
|
Ok(encoded)
|
|
}
|
|
|
|
fn assert_dimensions((width, height): (u32, u32)) -> Result<(u32, u32), PKAvatarError> {
|
|
if width > MAX_DIMENSION || height > MAX_DIMENSION {
|
|
return Err(PKAvatarError::ImageDimensionsTooLarge(
|
|
(width, height),
|
|
(MAX_DIMENSION, MAX_DIMENSION),
|
|
));
|
|
}
|
|
return Ok((width, height));
|
|
}
|
|
fn process_gif(input_data: &[u8], kind: ImageKind) -> Result<Option<ProcessOutput>, PKAvatarError> {
|
|
// gifs only supported for banners
|
|
if kind != ImageKind::Banner {
|
|
return Ok(None);
|
|
}
|
|
|
|
// and we can't rescale gifs (i tried :/) so the max size is the real limit
|
|
if kind != ImageKind::Banner {
|
|
return Ok(None);
|
|
}
|
|
|
|
let reader = gif::Decoder::new(Cursor::new(input_data)).map_err(Into::<anyhow::Error>::into)?;
|
|
let (max_width, max_height) = kind.size();
|
|
if reader.width() as u32 > max_width || reader.height() as u32 > max_height {
|
|
return Err(PKAvatarError::ImageDimensionsTooLarge(
|
|
(reader.width() as u32, reader.height() as u32),
|
|
(max_width, max_height),
|
|
));
|
|
}
|
|
Ok(process_gif_inner(reader).map_err(Into::<anyhow::Error>::into)?)
|
|
}
|
|
|
|
fn process_gif_inner(
|
|
mut reader: gif::Decoder<Cursor<&[u8]>>,
|
|
) -> Result<Option<ProcessOutput>, anyhow::Error> {
|
|
let time_before = Instant::now();
|
|
|
|
let (width, height) = (reader.width(), reader.height());
|
|
|
|
let mut writer = gif::Encoder::new(
|
|
Vec::new(),
|
|
width as u16,
|
|
height as u16,
|
|
reader.global_palette().unwrap_or(&[]),
|
|
)?;
|
|
writer.set_repeat(reader.repeat())?;
|
|
|
|
let mut frame_buf = Vec::new();
|
|
|
|
let mut frame_count = 0;
|
|
while let Some(frame) = reader.next_frame_info()? {
|
|
let mut frame = frame.clone();
|
|
assert_dimensions((frame.width as u32, frame.height as u32))?;
|
|
frame_buf.clear();
|
|
frame_buf.resize(reader.buffer_size(), 0);
|
|
reader.read_into_buffer(&mut frame_buf)?;
|
|
frame.buffer = Cow::Borrowed(&frame_buf);
|
|
|
|
frame.make_lzw_pre_encoded();
|
|
writer.write_lzw_pre_encoded_frame(&frame)?;
|
|
frame_count += 1;
|
|
}
|
|
|
|
if frame_count == 1 {
|
|
// If there's only one frame, then this doesn't need to be a gif. webp it
|
|
// (unfortunately we can't tell if there's only one frame until after the first frame's been decoded...)
|
|
return Ok(None);
|
|
}
|
|
|
|
let data = writer.into_inner()?;
|
|
let time_after = Instant::now();
|
|
|
|
let hash = Hash::sha256(&data);
|
|
|
|
let original_data = reader.into_inner();
|
|
info!(
|
|
"processed gif {}: {}K -> {}K ({} ms, frames: {})",
|
|
hash,
|
|
original_data.buffer().len() / 1024,
|
|
data.len() / 1024,
|
|
(time_after - time_before).as_millis(),
|
|
frame_count
|
|
);
|
|
|
|
Ok(Some(ProcessOutput {
|
|
data,
|
|
format: ProcessedFormat::Gif,
|
|
hash,
|
|
width: width as u32,
|
|
height: height as u32,
|
|
}))
|
|
}
|
|
|
|
fn reader_for(data: &[u8]) -> image::io::Reader<Cursor<&[u8]>> {
|
|
image::io::Reader::new(Cursor::new(data))
|
|
.with_guessed_format()
|
|
.expect("cursor i/o is infallible")
|
|
}
|
|
|
|
#[instrument(skip_all)]
|
|
fn resize(image: DynamicImage, kind: ImageKind) -> DynamicImage {
|
|
let (target_width, target_height) = kind.size();
|
|
if image.width() <= target_width && image.height() <= target_height {
|
|
// don't resize if already smaller
|
|
return image;
|
|
}
|
|
|
|
// todo: best filter?
|
|
let resized = image.resize(
|
|
target_width,
|
|
target_height,
|
|
image::imageops::FilterType::Lanczos3,
|
|
);
|
|
return resized;
|
|
}
|
|
|
|
#[instrument(skip_all)]
|
|
// can't believe this is infallible
|
|
fn encode(image: DynamicImage) -> ProcessOutput {
|
|
let (width, height) = (image.width(), image.height());
|
|
let image_buf = image.to_rgba8();
|
|
|
|
let encoded_lossy = webp::Encoder::new(&*image_buf, webp::PixelLayout::Rgba, width, height)
|
|
.encode_simple(false, 90.0)
|
|
.expect("encode should be infallible")
|
|
.to_vec();
|
|
|
|
let hash = Hash::sha256(&encoded_lossy);
|
|
|
|
ProcessOutput {
|
|
data: encoded_lossy,
|
|
format: ProcessedFormat::Webp,
|
|
hash,
|
|
width,
|
|
height,
|
|
}
|
|
}
|