add thumbnail dimension structure

Signed-off-by: Jason Volk <jason@zemos.net>
This commit is contained in:
Jason Volk 2024-08-27 01:15:31 +00:00
parent 7b0e830f4c
commit 4d42a29c51
7 changed files with 155 additions and 114 deletions

26
Cargo.lock generated
View file

@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "ruma"
version = "0.10.1"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"assign",
"js_int",
@ -2997,7 +2997,7 @@ dependencies = [
[[package]]
name = "ruma-appservice-api"
version = "0.10.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"js_int",
"ruma-common",
@ -3009,7 +3009,7 @@ dependencies = [
[[package]]
name = "ruma-client-api"
version = "0.18.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"as_variant",
"assign",
@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "ruma-common"
version = "0.13.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"as_variant",
"base64 0.22.1",
@ -3062,7 +3062,7 @@ dependencies = [
[[package]]
name = "ruma-events"
version = "0.28.1"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"as_variant",
"indexmap 2.4.0",
@ -3086,7 +3086,7 @@ dependencies = [
[[package]]
name = "ruma-federation-api"
version = "0.9.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"bytes",
"http",
@ -3104,7 +3104,7 @@ dependencies = [
[[package]]
name = "ruma-identifiers-validation"
version = "0.9.5"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"js_int",
"thiserror",
@ -3113,7 +3113,7 @@ dependencies = [
[[package]]
name = "ruma-identity-service-api"
version = "0.9.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"js_int",
"ruma-common",
@ -3123,7 +3123,7 @@ dependencies = [
[[package]]
name = "ruma-macros"
version = "0.13.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"once_cell",
"proc-macro-crate",
@ -3138,7 +3138,7 @@ dependencies = [
[[package]]
name = "ruma-push-gateway-api"
version = "0.9.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"js_int",
"ruma-common",
@ -3150,7 +3150,7 @@ dependencies = [
[[package]]
name = "ruma-server-util"
version = "0.3.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"headers",
"http",
@ -3163,7 +3163,7 @@ dependencies = [
[[package]]
name = "ruma-signatures"
version = "0.15.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
@ -3179,7 +3179,7 @@ dependencies = [
[[package]]
name = "ruma-state-res"
version = "0.11.0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=25fbd64b968c5d5088c07750aaa4873e072831b0#25fbd64b968c5d5088c07750aaa4873e072831b0"
source = "git+https://github.com/girlbossceo/ruwuma?rev=a0cc9a80dd5da700fb9b992b6f92cb6be4c27487#a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
dependencies = [
"itertools 0.12.1",
"js_int",

View file

@ -314,7 +314,7 @@ version = "0.1.2"
[workspace.dependencies.ruma]
git = "https://github.com/girlbossceo/ruwuma"
#branch = "conduwuit-changes"
rev = "25fbd64b968c5d5088c07750aaa4873e072831b0"
rev = "a0cc9a80dd5da700fb9b992b6f92cb6be4c27487"
features = [
"compat",
"rand",

View file

@ -14,7 +14,7 @@ use ruma::{
},
Mxc,
};
use service::media::{FileMeta, MXC_LENGTH};
use service::media::{Dim, FileMeta, MXC_LENGTH};
use crate::{Ruma, RumaResponse};
@ -326,22 +326,12 @@ pub(crate) async fn get_content_thumbnail_route(
media_id: &body.media_id,
};
let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?;
if let Some(FileMeta {
content,
content_type,
content_disposition,
}) = services
.media
.get_thumbnail(
&mxc,
body.width
.try_into()
.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?,
body.height
.try_into()
.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?,
)
.await?
}) = services.media.get_thumbnail(&mxc, &dim).await?
{
let content_disposition = make_content_disposition(content_disposition.as_ref(), content_type.as_deref(), None);

View file

@ -8,7 +8,7 @@ use conduit::{
use database::{Database, Map};
use ruma::{api::client::error::ErrorKind, http_headers::ContentDisposition, Mxc, OwnedMxcUri, UserId};
use super::preview::UrlPreviewData;
use super::{preview::UrlPreviewData, thumbnail::Dim};
pub(crate) struct Data {
mediaid_file: Arc<Map>,
@ -33,8 +33,8 @@ impl Data {
}
pub(super) fn create_file_metadata(
&self, mxc: &Mxc<'_>, user: Option<&UserId>, width: u32, height: u32,
content_disposition: Option<&ContentDisposition>, content_type: Option<&str>,
&self, mxc: &Mxc<'_>, user: Option<&UserId>, dim: &Dim, content_disposition: Option<&ContentDisposition>,
content_type: Option<&str>,
) -> Result<Vec<u8>> {
let mut key: Vec<u8> = Vec::new();
key.extend_from_slice(b"mxc://");
@ -42,8 +42,8 @@ impl Data {
key.extend_from_slice(b"/");
key.extend_from_slice(mxc.media_id.as_bytes());
key.push(0xFF);
key.extend_from_slice(&width.to_be_bytes());
key.extend_from_slice(&height.to_be_bytes());
key.extend_from_slice(&dim.width.to_be_bytes());
key.extend_from_slice(&dim.height.to_be_bytes());
key.push(0xFF);
key.extend_from_slice(
content_disposition
@ -128,15 +128,15 @@ impl Data {
Ok(keys)
}
pub(super) fn search_file_metadata(&self, mxc: &Mxc<'_>, width: u32, height: u32) -> Result<Metadata> {
pub(super) fn search_file_metadata(&self, mxc: &Mxc<'_>, dim: &Dim) -> Result<Metadata> {
let mut prefix: Vec<u8> = Vec::new();
prefix.extend_from_slice(b"mxc://");
prefix.extend_from_slice(mxc.server_name.as_bytes());
prefix.extend_from_slice(b"/");
prefix.extend_from_slice(mxc.media_id.as_bytes());
prefix.push(0xFF);
prefix.extend_from_slice(&width.to_be_bytes());
prefix.extend_from_slice(&height.to_be_bytes());
prefix.extend_from_slice(&dim.width.to_be_bytes());
prefix.extend_from_slice(&dim.height.to_be_bytes());
prefix.push(0xFF);
let (key, _) = self

View file

@ -13,13 +13,14 @@ use conduit::{
utils::{self, MutexMap},
warn, Err, Result, Server,
};
use data::{Data, Metadata};
use ruma::{http_headers::ContentDisposition, Mxc, OwnedMxcUri, UserId};
use tokio::{
fs,
io::{AsyncReadExt, AsyncWriteExt, BufReader},
};
use self::data::{Data, Metadata};
pub use self::thumbnail::Dim;
use crate::{client, globals, sending, Dep};
#[derive(Debug)]
@ -78,7 +79,7 @@ impl Service {
// Width, Height = 0 if it's not a thumbnail
let key = self
.db
.create_file_metadata(mxc, user, 0, 0, content_disposition, content_type)?;
.create_file_metadata(mxc, user, &Dim::default(), content_disposition, content_type)?;
//TODO: Dangling metadata in database if creation fails
let mut f = self.create_media_file(&key).await?;
@ -141,7 +142,7 @@ impl Service {
content_disposition,
content_type,
key,
}) = self.db.search_file_metadata(mxc, 0, 0)
}) = self.db.search_file_metadata(mxc, &Dim::default())
{
let mut content = Vec::new();
let path = self.get_media_file(&key);
@ -350,7 +351,7 @@ impl Service {
#[inline]
pub fn get_metadata(&self, mxc: &Mxc<'_>) -> Option<FileMeta> {
self.db
.search_file_metadata(mxc, 0, 0)
.search_file_metadata(mxc, &Dim::default())
.map(|metadata| FileMeta {
content_disposition: metadata.content_disposition,
content_type: metadata.content_type,

View file

@ -3,6 +3,8 @@ use std::time::Duration;
use conduit::{debug_warn, err, implement, utils::content_disposition::make_content_disposition, Err, Error, Result};
use ruma::{api::client::media, Mxc};
use super::Dim;
#[implement(super::Service)]
#[allow(deprecated)]
pub async fn fetch_remote_thumbnail_legacy(
@ -33,20 +35,9 @@ pub async fn fetch_remote_thumbnail_legacy(
)
.await?;
self.upload_thumbnail(
&mxc,
None,
None,
reponse.content_type.as_deref(),
body.width
.try_into()
.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?,
body.height
.try_into()
.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?,
&reponse.file,
)
.await?;
let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?;
self.upload_thumbnail(&mxc, None, None, reponse.content_type.as_deref(), &dim, &reponse.file)
.await?;
Ok(reponse)
}

View file

@ -1,8 +1,8 @@
use std::{cmp, io::Cursor, num::Saturating as Sat};
use conduit::{checked, Result};
use conduit::{checked, err, Result};
use image::{imageops::FilterType, DynamicImage};
use ruma::{http_headers::ContentDisposition, Mxc, UserId};
use ruma::{http_headers::ContentDisposition, media::Method, Mxc, UInt, UserId};
use tokio::{
fs,
io::{AsyncReadExt, AsyncWriteExt},
@ -10,16 +10,24 @@ use tokio::{
use super::{data::Metadata, FileMeta};
/// Dimension specification for a thumbnail.
#[derive(Debug)]
pub struct Dim {
pub width: u32,
pub height: u32,
pub method: Method,
}
impl super::Service {
/// Uploads or replaces a file thumbnail.
#[allow(clippy::too_many_arguments)]
pub async fn upload_thumbnail(
&self, mxc: &Mxc<'_>, user: Option<&UserId>, content_disposition: Option<&ContentDisposition>,
content_type: Option<&str>, width: u32, height: u32, file: &[u8],
content_type: Option<&str>, dim: &Dim, file: &[u8],
) -> Result<()> {
let key = self
.db
.create_file_metadata(mxc, user, width, height, content_disposition, content_type)?;
.create_file_metadata(mxc, user, dim, content_disposition, content_type)?;
//TODO: Dangling metadata in database if creation fails
let mut f = self.create_media_file(&key).await?;
@ -42,15 +50,14 @@ impl super::Service {
/// For width,height <= 96 the server uses another thumbnailing algorithm
/// which crops the image afterwards.
#[tracing::instrument(skip(self), name = "thumbnail", level = "debug")]
pub async fn get_thumbnail(&self, mxc: &Mxc<'_>, width: u32, height: u32) -> Result<Option<FileMeta>> {
pub async fn get_thumbnail(&self, mxc: &Mxc<'_>, dim: &Dim) -> Result<Option<FileMeta>> {
// 0, 0 because that's the original file
let (width, height, crop) = thumbnail_properties(width, height).unwrap_or((0, 0, false));
let dim = dim.normalized();
if let Ok(metadata) = self.db.search_file_metadata(mxc, width, height) {
if let Ok(metadata) = self.db.search_file_metadata(mxc, &dim) {
self.get_thumbnail_saved(metadata).await
} else if let Ok(metadata) = self.db.search_file_metadata(mxc, 0, 0) {
self.get_thumbnail_generate(mxc, width, height, crop, metadata)
.await
} else if let Ok(metadata) = self.db.search_file_metadata(mxc, &Dim::default()) {
self.get_thumbnail_generate(mxc, &dim, metadata).await
} else {
Ok(None)
}
@ -71,9 +78,7 @@ impl super::Service {
/// Generate a thumbnail
#[tracing::instrument(skip(self), name = "generate", level = "debug")]
async fn get_thumbnail_generate(
&self, mxc: &Mxc<'_>, width: u32, height: u32, crop: bool, data: Metadata,
) -> Result<Option<FileMeta>> {
async fn get_thumbnail_generate(&self, mxc: &Mxc<'_>, dim: &Dim, data: Metadata) -> Result<Option<FileMeta>> {
let mut content = Vec::new();
let path = self.get_media_file(&data.key);
fs::File::open(path)
@ -86,20 +91,19 @@ impl super::Service {
return Ok(Some(into_filemeta(data, content)));
};
if width > image.width() || height > image.height() {
if dim.width > image.width() || dim.height > image.height() {
return Ok(Some(into_filemeta(data, content)));
}
let mut thumbnail_bytes = Vec::new();
let thumbnail = thumbnail_generate(&image, width, height, crop)?;
let thumbnail = thumbnail_generate(&image, dim)?;
thumbnail.write_to(&mut Cursor::new(&mut thumbnail_bytes), image::ImageFormat::Png)?;
// Save thumbnail in database so we don't have to generate it again next time
let thumbnail_key = self.db.create_file_metadata(
mxc,
None,
width,
height,
dim,
data.content_disposition.as_ref(),
data.content_type.as_deref(),
)?;
@ -111,56 +115,25 @@ impl super::Service {
}
}
fn thumbnail_generate(image: &DynamicImage, width: u32, height: u32, crop: bool) -> Result<DynamicImage> {
let thumbnail = if crop {
image.resize_to_fill(width, height, FilterType::CatmullRom)
fn thumbnail_generate(image: &DynamicImage, requested: &Dim) -> Result<DynamicImage> {
let thumbnail = if !requested.crop() {
let Dim {
width,
height,
..
} = requested.scaled(&Dim {
width: image.width(),
height: image.height(),
..Dim::default()
})?;
image.thumbnail_exact(width, height)
} else {
let (exact_width, exact_height) = thumbnail_dimension(image, width, height)?;
image.thumbnail_exact(exact_width, exact_height)
image.resize_to_fill(requested.width, requested.height, FilterType::CatmullRom)
};
Ok(thumbnail)
}
fn thumbnail_dimension(image: &DynamicImage, width: u32, height: u32) -> Result<(u32, u32)> {
let image_width = image.width();
let image_height = image.height();
let width = cmp::min(width, image_width);
let height = cmp::min(height, image_height);
let use_width = Sat(width) * Sat(image_height) < Sat(height) * Sat(image_width);
let x = if use_width {
let dividend = (Sat(height) * Sat(image_width)).0;
checked!(dividend / image_height)?
} else {
width
};
let y = if !use_width {
let dividend = (Sat(width) * Sat(image_height)).0;
checked!(dividend / image_width)?
} else {
height
};
Ok((x, y))
}
/// Returns width, height of the thumbnail and whether it should be cropped.
/// Returns None when the server should send the original file.
fn thumbnail_properties(width: u32, height: u32) -> Option<(u32, u32, bool)> {
match (width, height) {
(0..=32, 0..=32) => Some((32, 32, true)),
(0..=96, 0..=96) => Some((96, 96, true)),
(0..=320, 0..=240) => Some((320, 240, false)),
(0..=640, 0..=480) => Some((640, 480, false)),
(0..=800, 0..=600) => Some((800, 600, false)),
_ => None,
}
}
fn into_filemeta(data: Metadata, content: Vec<u8>) -> FileMeta {
FileMeta {
content: Some(content),
@ -168,3 +141,89 @@ fn into_filemeta(data: Metadata, content: Vec<u8>) -> FileMeta {
content_disposition: data.content_disposition,
}
}
impl Dim {
/// Instantiate a Dim from Ruma integers with optional method.
pub fn from_ruma(width: UInt, height: UInt, method: Option<Method>) -> Result<Self> {
let width = width
.try_into()
.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?;
let height = height
.try_into()
.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?;
Ok(Self::new(width, height, method))
}
/// Instantiate a Dim with optional method
#[inline]
#[must_use]
pub fn new(width: u32, height: u32, method: Option<Method>) -> Self {
Self {
width,
height,
method: method.unwrap_or(Method::Scale),
}
}
pub fn scaled(&self, image: &Self) -> Result<Self> {
let image_width = image.width;
let image_height = image.height;
let width = cmp::min(self.width, image_width);
let height = cmp::min(self.height, image_height);
let use_width = Sat(width) * Sat(image_height) < Sat(height) * Sat(image_width);
let x = if use_width {
let dividend = (Sat(height) * Sat(image_width)).0;
checked!(dividend / image_height)?
} else {
width
};
let y = if !use_width {
let dividend = (Sat(width) * Sat(image_height)).0;
checked!(dividend / image_width)?
} else {
height
};
Ok(Self {
width: x,
height: y,
method: Method::Scale,
})
}
/// Returns width, height of the thumbnail and whether it should be cropped.
/// Returns None when the server should send the original file.
/// Ignores the input Method.
#[must_use]
pub fn normalized(&self) -> Self {
match (self.width, self.height) {
(0..=32, 0..=32) => Self::new(32, 32, Some(Method::Crop)),
(0..=96, 0..=96) => Self::new(96, 96, Some(Method::Crop)),
(0..=320, 0..=240) => Self::new(320, 240, Some(Method::Scale)),
(0..=640, 0..=480) => Self::new(640, 480, Some(Method::Scale)),
(0..=800, 0..=600) => Self::new(800, 600, Some(Method::Scale)),
_ => Self::default(),
}
}
/// Returns true if the method is Crop.
#[inline]
#[must_use]
pub fn crop(&self) -> bool { self.method == Method::Crop }
}
impl Default for Dim {
#[inline]
fn default() -> Self {
Self {
width: 0,
height: 0,
method: Method::Scale,
}
}
}