tuwunel_service/media/thumbnail/
generate.rs1use std::{cmp::min, io::Cursor};
4
5use image::{DynamicImage, ImageFormat, ImageReader, Limits, imageops::FilterType};
6use ruma::{
7 Mxc,
8 http_headers::{ContentDisposition, ContentDispositionType},
9 media::Method,
10};
11use tuwunel_core::{Err, Result, err, implement, utils::BoolExt};
12
13#[cfg(test)]
14use super::tests::caller_after_animation;
15use super::{
16 super::{Media, data::Metadata},
17 Animate, Dim,
18 request::into_media,
19 sniff::{Sequence, sequence},
20};
21
22const PNG: &str = "image/png";
24
25pub(super) const BYTES_PER_PIXEL: u64 = 4;
27
28const STILL_NAME: &str = "thumbnail.png";
31
32pub(super) fn picture_dim(bytes: &[u8]) -> Dim { header_dim(bytes).unwrap_or_else(Dim::largest) }
38
39fn header_dim(bytes: &[u8]) -> Option<Dim> {
45 reader(bytes)
46 .ok()
47 .and_then(|reader| reader.into_dimensions().ok())
48 .filter(|&(width, height)| width > 0 && height > 0)
49 .map(|(width, height)| Dim::new(width, height, Some(Method::Scale)))
50}
51#[implement(super::super::Service)]
56pub(super) fn decode_still(&self, bytes: &[u8]) -> Result<DynamicImage> {
57 self.decode(bytes)
58 .map_err(|_| err!(Request(NotFound("Media thumbnail not found."))))
59}
60
61#[implement(super::super::Service)]
67#[tracing::instrument(name = "still", level = "debug", skip(self, animated))]
68pub(in super::super) async fn store_still(
69 &self,
70 mxc: &Mxc<'_>,
71 dim: &Dim,
72 animated: Media,
73) -> Result<Media> {
74 let standin = dim
77 .is_original()
78 .then(|| picture_dim(&animated.content));
79
80 let image = self.decode_still(&animated.content)?;
81
82 drop(animated);
83
84 self.store_thumbnail(mxc, standin.as_ref().unwrap_or(dim), image)
85 .await
86}
87
88#[implement(super::super::Service)]
95#[tracing::instrument(name = "generate", level = "debug", skip(self, data))]
96pub(super) async fn get_thumbnail_generate(
97 &self,
98 mxc: &Mxc<'_>,
99 dim: &Dim,
100 animate: Animate,
101 data: Metadata,
102) -> Result<Media> {
103 let animation_enabled = self.services.config.media_thumbnail_animated;
104 let admission = animation_enabled
105 .then_async(async || {
106 let slots = self.animated_thumbnail_slots.clone();
107 let Ok(admission) = slots.acquire_owned().await else {
108 return Err!(debug_warn!("The animated thumbnail semaphore is closed."));
109 };
110
111 Ok(admission)
112 })
113 .await
114 .transpose()?;
115
116 let bytes = self.fetch_bytes(&data.key).await?;
117
118 let walk = animation_enabled.then(|| sequence(&bytes));
120
121 let (encoded, admission, bytes) = if animates_at(dim, walk, &bytes)? {
122 let permit = admission.expect("animation work has source admission");
123 let output = self
124 .store_animated(mxc, dim, bytes, permit)
125 .await?;
126
127 #[cfg(test)]
128 caller_after_animation().await;
129
130 (output.media.ok(), Some(output.admission), output.source)
131 } else {
132 (None, admission, bytes)
133 };
134
135 let animated = animate.allowed().and(encoded);
138
139 let media = into_media(data, bytes.into());
140 let frame = self.video_frame(mxc, dim, &media).await;
141 let from_video = frame.is_some();
142
143 let Ok(image) = self.decode(frame.as_deref().unwrap_or(&media.content)) else {
144 if from_video {
147 self.remember_failure(mxc);
148 }
149
150 if let Some(animated) = animated {
151 return Ok(animated);
152 }
153
154 let named = walk.map(Sequence::names_animation);
158
159 return match animate.accepts_fallback_walk(named, &media.content) {
160 | true => Ok(media),
161 | false => Err!(Request(NotFound("Media thumbnail not found."))),
162 };
163 };
164
165 drop(frame);
166
167 let source = Dim::new(image.width(), image.height(), None);
170 let animates = walk.map(Sequence::animates);
171
172 if !from_video
173 && dim.is_passthrough(&source)?
174 && animate.accepts_walk(animates, &media.content)
175 {
176 return Ok(media);
177 }
178
179 drop(media);
182
183 let still = self.store_thumbnail(mxc, dim, image).await?;
184
185 drop(admission);
186
187 Ok(animated.unwrap_or(still))
188}
189
190fn animates_at(dim: &Dim, walk: Option<Sequence>, content: &[u8]) -> Result<bool> {
200 if !walk.is_some_and(Sequence::animates) {
201 return Ok(false);
202 }
203
204 let Some(source) = header_dim(content) else {
207 return Ok(false);
208 };
209
210 dim.is_passthrough(&source)
211 .map(|through| !through)
212}
213
214#[implement(super::super::Service)]
219#[tracing::instrument(name = "store", level = "debug", skip(self, image))]
220pub(super) async fn store_thumbnail(
221 &self,
222 mxc: &Mxc<'_>,
223 dim: &Dim,
224 image: DynamicImage,
225) -> Result<Media> {
226 let thumbnail = thumbnail_generate(&image, dim)?;
227
228 drop(image);
231
232 let content = encode_png(thumbnail)?;
233
234 self.store_encoded(mxc, dim, content, PNG, STILL_NAME)
235 .await
236}
237
238fn encode_png(thumbnail: DynamicImage) -> Result<Vec<u8>> {
242 let mut content = Vec::new();
243 let () = {
244 let mut cursor = Cursor::new(&mut content);
245
246 thumbnail
247 .write_to(&mut cursor, ImageFormat::Png)
248 .map_err(|error| err!(error!(?error, "Error writing PNG thumbnail.")))?;
249 };
250
251 drop(thumbnail);
252
253 Ok(content)
254}
255
256#[implement(super::super::Service)]
262#[tracing::instrument(name = "encoded", level = "debug", skip(self, content))]
263pub(super) async fn store_encoded(
264 &self,
265 mxc: &Mxc<'_>,
266 dim: &Dim,
267 content: Vec<u8>,
268 content_type: &str,
269 filename: &str,
270) -> Result<Media> {
271 let content_disposition = ContentDisposition {
272 disposition_type: ContentDispositionType::Inline,
273 filename: Some(filename.to_owned()),
274 };
275
276 let key = self.db.create_file_metadata(
277 mxc,
278 None,
279 dim,
280 Some(&content_disposition),
281 Some(content_type),
282 )?;
283
284 self.create_media_file(&key, &content).await?;
285
286 let media = Media {
287 content,
288 content_type: Some(content_type.to_owned()),
289 content_disposition: Some(content_disposition),
290 };
291
292 Ok(media)
293}
294
295#[implement(super::super::Service)]
301#[tracing::instrument(name = "decode", level = "trace", skip_all)]
302pub(super) fn decode(&self, bytes: &[u8]) -> Result<DynamicImage> {
303 let budget = self.services.config.media_thumbnail_max_pixels;
304 let (width, height) = reader(bytes)?
305 .into_dimensions()
306 .map_err(|error| err!(debug_warn!(?error, "Failed to read picture dimensions.")))?;
307
308 let pixels = u64::from(width).saturating_mul(u64::from(height));
309
310 if pixels > budget {
311 return Err!(debug_warn!(%width, %height, "Picture is past the {budget} pixel budget."));
312 }
313
314 limited_reader(bytes, budget)?
315 .decode()
316 .map_err(|error| err!(debug_warn!(?error, "Failed to decode picture.")))
317}
318
319fn limited_reader(bytes: &[u8], budget: u64) -> Result<ImageReader<Cursor<&[u8]>>> {
324 let mut reader = reader(bytes)?;
325
326 reader.limits(decoder_limits(budget));
327
328 Ok(reader)
329}
330
331fn decoder_limits(budget: u64) -> Limits {
335 let mut limits = Limits::no_limits();
336
337 limits.max_alloc = Some(budget.saturating_mul(BYTES_PER_PIXEL));
338
339 limits
340}
341
342pub(super) fn reader(bytes: &[u8]) -> Result<ImageReader<Cursor<&[u8]>>> {
347 ImageReader::new(Cursor::new(bytes))
348 .with_guessed_format()
349 .map_err(Into::into)
350}
351
352pub(in super::super) fn thumbnail_generate(
357 image: &DynamicImage,
358 requested: &Dim,
359) -> Result<DynamicImage> {
360 let thumbnail = if !requested.crop() {
361 let Dim { width, height, .. } = requested.scaled(&Dim {
362 width: image.width(),
363 height: image.height(),
364 ..Dim::default()
365 })?;
366
367 image.thumbnail_exact(width, height)
368 } else {
369 let width = min(requested.width, image.width());
372 let height = min(requested.height, image.height());
373
374 image.resize_to_fill(width, height, FilterType::CatmullRom)
375 };
376
377 Ok(thumbnail)
378}