Files
AstralRinth/apps/labrinth/src/util/img.rs
Josiah Glosson 650ab71a83 Commonized networking (#3310)
* Fix not being able to connect to local friends socket

* Start basic work on tunneling protocol and move some code into a common crate

* Commonize message serialization logic

* Serialize Base62Ids as u64 when human-readability is not required

* Move ActiveSockets tuple into struct

* Make CI run when rust-common is updated

CI is currently broken for labrinth, however

* Fix theseus-release.yml to reference itself correctly

* Implement Labrinth side of tunneling

* Implement non-friend part of theseus tunneling

* Implement client-side except for socket loop

* Implement the socket loop

Doesn't work though. Debugging time!

* Fix config.rs

* Fix deadlock in labrinth socket handling

* Update dockerfile

* switch to workspace prepare at root level

* Wait for connection before tunneling in playground

* Move rust-common into labrinth

* Remove rust-common references from Actions

* Revert "Update dockerfile"

This reverts commit 3caad59bb474ce425d0b8928d7cee7ae1a5011bd.

* Fix Docker build

* Rebuild Theseus if common code changes

* Allow multiple connections from the same user

* Fix test building

* Move FriendSocketListening and FriendSocketStoppedListening to non-panicking TODO for now

* Make message_serialization macro take varargs for binary messages

* Improve syntax of message_serialization macro

* Remove the ability to connect to a virtual socket, and disable the ability to listen on one

* Allow the app to compile without running labrinth

* Clippy fix

* Update Rust and Clippy fix again

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2025-02-28 10:52:47 -08:00

222 lines
6.5 KiB
Rust

use crate::database;
use crate::database::models::image_item;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::images::ImageContext;
use crate::routes::ApiError;
use color_thief::ColorFormat;
use image::imageops::FilterType;
use image::{
DynamicImage, EncodableLayout, GenericImageView, ImageError,
ImageOutputFormat,
};
use std::io::Cursor;
use webp::Encoder;
pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
let image = image::load_from_memory(data)?
.resize(256, 256, FilterType::Nearest)
.crop_imm(128, 128, 64, 64);
let color = color_thief::get_palette(
image.to_rgb8().as_bytes(),
ColorFormat::Rgb,
10,
2,
)
.ok()
.and_then(|x| x.first().copied())
.map(|x| ((x.r as u32) << 16) | ((x.g as u32) << 8) | (x.b as u32));
Ok(color)
}
pub struct UploadImageResult {
pub url: String,
pub url_path: String,
pub raw_url: String,
pub raw_url_path: String,
pub color: Option<u32>,
}
pub async fn upload_image_optimized(
upload_folder: &str,
bytes: bytes::Bytes,
file_extension: &str,
target_width: Option<u32>,
min_aspect_ratio: Option<f32>,
file_host: &dyn FileHost,
) -> Result<UploadImageResult, ApiError> {
let content_type = crate::util::ext::get_image_content_type(file_extension)
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Invalid format for image: {}",
file_extension
))
})?;
let cdn_url = dotenvy::var("CDN_URL")?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let (processed_image, processed_image_ext) = process_image(
bytes.clone(),
content_type,
target_width,
min_aspect_ratio,
)?;
let color = get_color_from_img(&bytes)?;
// Only upload the processed image if it's smaller than the original
let processed_upload_data = if processed_image.len() < bytes.len() {
Some(
file_host
.upload_file(
content_type,
&format!(
"{}/{}_{}.{}",
upload_folder,
hash,
target_width.unwrap_or(0),
processed_image_ext
),
processed_image,
)
.await?,
)
} else {
None
};
let upload_data = file_host
.upload_file(
content_type,
&format!("{}/{}.{}", upload_folder, hash, file_extension),
bytes,
)
.await?;
let url = format!("{}/{}", cdn_url, upload_data.file_name);
Ok(UploadImageResult {
url: processed_upload_data
.clone()
.map(|x| format!("{}/{}", cdn_url, x.file_name))
.unwrap_or_else(|| url.clone()),
url_path: processed_upload_data
.map(|x| x.file_name)
.unwrap_or_else(|| upload_data.file_name.clone()),
raw_url: url,
raw_url_path: upload_data.file_name,
color,
})
}
fn process_image(
image_bytes: bytes::Bytes,
content_type: &str,
target_width: Option<u32>,
min_aspect_ratio: Option<f32>,
) -> Result<(bytes::Bytes, String), ImageError> {
if content_type.to_lowercase() == "image/gif" {
return Ok((image_bytes.clone(), "gif".to_string()));
}
let mut img = image::load_from_memory(&image_bytes)?;
let webp_bytes = convert_to_webp(&img)?;
img = image::load_from_memory(&webp_bytes)?;
// Resize the image
let (orig_width, orig_height) = img.dimensions();
let aspect_ratio = orig_width as f32 / orig_height as f32;
if let Some(target_width) = target_width {
if img.width() > target_width {
let new_height =
(target_width as f32 / aspect_ratio).round() as u32;
img = img.resize(target_width, new_height, FilterType::Lanczos3);
}
}
if let Some(min_aspect_ratio) = min_aspect_ratio {
// Crop if necessary
if aspect_ratio < min_aspect_ratio {
let crop_height =
(img.width() as f32 / min_aspect_ratio).round() as u32;
let y_offset = (img.height() - crop_height) / 2;
img = img.crop_imm(0, y_offset, img.width(), crop_height);
}
}
// Optimize and compress
let mut output = Vec::new();
img.write_to(&mut Cursor::new(&mut output), ImageOutputFormat::WebP)?;
Ok((bytes::Bytes::from(output), "webp".to_string()))
}
fn convert_to_webp(img: &DynamicImage) -> Result<Vec<u8>, ImageError> {
let rgba = img.to_rgba8();
let encoder = Encoder::from_rgba(&rgba, img.width(), img.height());
let webp = encoder.encode(75.0); // Quality factor: 0-100, 75 is a good balance
Ok(webp.to_vec())
}
pub async fn delete_old_images(
image_url: Option<String>,
raw_image_url: Option<String>,
file_host: &dyn FileHost,
) -> Result<(), ApiError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let cdn_url_start = format!("{cdn_url}/");
if let Some(image_url) = image_url {
let name = image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
if let Some(raw_image_url) = raw_image_url {
let name = raw_image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
Ok(())
}
// check changes to associated images
// if they no longer exist in the String list, delete them
// Eg: if description is modified and no longer contains a link to an iamge
pub async fn delete_unused_images(
context: ImageContext,
reference_strings: Vec<&str>,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> Result<(), ApiError> {
let uploaded_images =
database::models::Image::get_many_contexted(context, transaction)
.await?;
for image in uploaded_images {
let mut should_delete = true;
for reference in &reference_strings {
if image.url.contains(reference) {
should_delete = false;
break;
}
}
if should_delete {
image_item::Image::remove(image.id, transaction, redis).await?;
image_item::Image::clear_cache(image.id, redis).await?;
}
}
Ok(())
}