Skip to main content

tuwunel_service/media/thumbnail/
dimension.rs

1//! Defines thumbnail dimensions and scaling rules.
2//!
3//! A dimension carries the requested extent and resize method, then normalizes
4//! them to the bounded variants stored by the media service.
5
6use std::{cmp::min, num::Saturating as Sat};
7
8use ruma::{UInt, media::Method};
9use tuwunel_core::{Result, checked, err, implement};
10
11/// Dimension specification for a thumbnail.
12///
13/// Width and height describe the requested output extent. The method selects
14/// proportional scaling or cropping to fill that extent.
15#[derive(Debug)]
16pub struct Dim {
17	/// Requested output width in pixels.
18	pub width: u32,
19
20	/// Requested output height in pixels.
21	pub height: u32,
22
23	/// Resize operation applied to the source.
24	pub method: Method,
25}
26
27/// Creates dimensions from Ruma integers.
28///
29/// Both values are checked before conversion, and an absent method uses the
30/// endpoint's scaling default.
31#[implement(Dim)]
32pub fn from_ruma(width: UInt, height: UInt, method: Option<Method>) -> Result<Self> {
33	let width = width
34		.try_into()
35		.map_err(|e| err!(Request(InvalidParam("Width is invalid: {e:?}"))))?;
36
37	let height = height
38		.try_into()
39		.map_err(|e| err!(Request(InvalidParam("Height is invalid: {e:?}"))))?;
40
41	Ok(Self::new(width, height, method))
42}
43
44/// Creates dimensions with an optional method.
45///
46/// An absent method selects proportional scaling.
47#[implement(Dim)]
48#[inline]
49#[must_use]
50pub fn new(width: u32, height: u32, method: Option<Method>) -> Self {
51	Self {
52		width,
53		height,
54		method: method.unwrap_or(Method::Scale),
55	}
56}
57
58/// Scales dimensions to fit within a source.
59///
60/// The result preserves the source aspect ratio and never exceeds either the
61/// requested extent or the source extent.
62#[implement(Dim)]
63pub fn scaled(&self, image: &Self) -> Result<Self> {
64	let image_width = image.width;
65	let image_height = image.height;
66
67	let width = min(self.width, image_width);
68	let height = min(self.height, image_height);
69
70	let use_width = Sat(width) * Sat(image_height) < Sat(height) * Sat(image_width);
71
72	let x = if use_width {
73		let dividend = (Sat(height) * Sat(image_width)).0;
74		checked!(dividend / image_height)?
75	} else {
76		width
77	};
78
79	let y = if !use_width {
80		let dividend = (Sat(width) * Sat(image_height)).0;
81		checked!(dividend / image_width)?
82	} else {
83		height
84	};
85
86	Ok(Self {
87		width: x,
88		height: y,
89		method: Method::Scale,
90	})
91}
92
93/// Returns whether generation cannot improve on the source.
94///
95/// A request passes through when it would upscale the source or when scaling
96/// produces the source's own dimensions.
97#[implement(Dim)]
98pub fn is_passthrough(&self, source: &Self) -> Result<bool> {
99	if self.width > source.width || self.height > source.height {
100		return Ok(true);
101	}
102
103	let (width, height) = if self.crop() {
104		(self.width, self.height)
105	} else {
106		let scaled = self.scaled(source)?;
107
108		(scaled.width, scaled.height)
109	};
110
111	Ok(width == source.width && height == source.height)
112}
113
114/// The size a requested one is answered at.
115///
116/// Bucketing keeps the number of stored variants bounded, so the requested
117/// method is discarded along with the requested size. A request above every
118/// bucket answers the sentinel, which stands for the original file.
119#[implement(Dim)]
120#[must_use]
121pub fn normalized(&self) -> Self {
122	match (self.width, self.height) {
123		| (0..=32, 0..=32) => Self::new(32, 32, Some(Method::Crop)),
124		| (0..=96, 0..=96) => Self::new(96, 96, Some(Method::Crop)),
125		| (0..=320, 0..=240) => Self::new(320, 240, Some(Method::Scale)),
126		| (0..=640, 0..=480) => Self::new(640, 480, Some(Method::Scale)),
127		| (0..=800, 0..=600) => Self::largest(),
128		| _ => Self::default(),
129	}
130}
131
132/// The largest size a thumbnail is generated at.
133///
134/// Every request above this normalizes to the sentinel instead, so it is
135/// also the fallback for a picture whose own size cannot be read.
136#[implement(Dim)]
137#[inline]
138#[must_use]
139pub fn largest() -> Self { Self::new(800, 600, Some(Method::Scale)) }
140
141/// Returns whether the method crops.
142///
143/// Scaling preserves the whole source, while cropping fills the requested
144/// extent.
145#[implement(Dim)]
146#[inline]
147#[must_use]
148pub fn crop(&self) -> bool { self.method == Method::Crop }
149
150/// Returns true for the sentinel that stands for the original file.
151///
152/// Every request too large to thumbnail normalizes to zero by zero, which
153/// is also the key the original itself is stored under, so nothing may be
154/// withheld there.
155#[implement(Dim)]
156#[inline]
157#[must_use]
158pub fn is_original(&self) -> bool { self.width == 0 && self.height == 0 }
159
160impl Default for Dim {
161	#[inline]
162	fn default() -> Self {
163		Self {
164			width: 0,
165			height: 0,
166			method: Method::Scale,
167		}
168	}
169}