Skip to main content

tuwunel_service/media/thumbnail/
generate.rs

1//! Generates and stores still and animated thumbnail variants.
2
3use 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
22/// Content type of every still thumbnail tuwunel generates.
23const PNG: &str = "image/png";
24
25/// Bytes the decoder is budgeted per pixel of the picture it is asked for.
26pub(super) const BYTES_PER_PIXEL: u64 = 4;
27
28/// Filename a generated still thumbnail is disposed under, per the media
29/// repository specification, rather than the source filename.
30const STILL_NAME: &str = "thumbnail.png";
31
32/// The size a picture that may not be served stands in at.
33///
34/// Only the header is read, since the decode this precedes may never happen.
35/// A header that will not read at all falls back to the largest bucket, which
36/// answers smaller than the request but never with the animation.
37pub(super) fn picture_dim(bytes: &[u8]) -> Dim { header_dim(bytes).unwrap_or_else(Dim::largest) }
38
39/// The dimensions this picture's own header declares.
40///
41/// A header that will not read, or that reads as having no extent, answers
42/// `None` rather than a size nothing could be encoded at. Callers that must
43/// name a size regardless take [`picture_dim`] instead.
44fn 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/// Decodes a picture that may not be served in the state it is held in.
52///
53/// Serving it is what the request forbade, so a decode that fails leaves
54/// nothing answerable and the caller has no fallback to offer.
55#[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/// Re-encode a fetched picture as a still thumbnail and store it.
62///
63/// A peer that ignores the parameter answers a cold fetch with animation, and
64/// this repairs it on the way out rather than one request later. The cached
65/// row takes its own path, which already holds the picture.
66#[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	// the sentinel names the original rather than a size to re-encode at, so
75	// the picture stands in at its own, as it does on the lookup path
76	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/// Generate a thumbnail.
89///
90/// A source that animates yields both variants here rather than the one this
91/// request asked for. Generation runs only on a lookup miss, and the row it
92/// stores is what stops the next miss, so a variant left ungenerated would wait
93/// on a miss that the other variant has already made impossible.
94#[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	// the gates below read this walk too, so it is taken at most once
119	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	// the variant this request did not ask for is left stored rather than held,
136	// so its buffer is not carried across the still encode below
137	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		// a frame the thumbnailer refuses is this video's verdict too; without
145		// it the program would run again on the next request for any size
146		if from_video {
147			self.remember_failure(mxc);
148		}
149
150		if let Some(animated) = animated {
151			return Ok(animated);
152		}
153
154		// no still can be derived from a picture that will not decode, and the
155		// original answers in its place unless it is the animation the request
156		// forbade
157		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	// a video is never servable in place of its own thumbnail, so its frame is
168	// re-encoded however small it is
169	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	// nothing below reads the original, which on the video path is the whole
180	// staged file, and the encode and the store must not hold it
181	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
190/// Whether this source yields an animated variant at these dimensions.
191///
192/// Only a picture whose header says it holds a frame sequence reaches the
193/// encoder, so a still never pays a decode that would yield one frame and be
194/// thrown away, and a video is excluded by the same test since its container is
195/// none of the three that hold frames. A size the request cannot improve on is
196/// passed through whole below rather than re-encoded. The caller takes the
197/// walk and reads it again at that passthrough, and hands `None` where the
198/// feature is off.
199fn 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	// the passthrough below is tested against the decoded size, so a header
205	// that will not read would leave the two disagreeing over one picture
206	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/// Encode a still PNG thumbnail at these dimensions and store it.
215///
216/// The generate path and the still-repair path share this, so a given size
217/// carries one content type and one disposition whichever produced it.
218#[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	// the source raster is dead once the thumbnail exists, and neither it nor
229	// the thumbnail may be held across the store below
230	drop(image);
231
232	let content = encode_png(thumbnail)?;
233
234	self.store_encoded(mxc, dim, content, PNG, STILL_NAME)
235		.await
236}
237
238/// Encodes one still thumbnail into a PNG buffer.
239///
240/// The buffer and writer remain confined to this synchronous kernel.
241fn 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/// Store an encoded thumbnail under the type and name it carries.
257///
258/// Both encoders end here, so a stored thumbnail is disposed inline under the
259/// name the media repository specification asks of one whether or not the
260/// original arrived with a name of its own.
261#[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/// Decode a picture whose header declares no more than the configured pixel
296/// count.
297///
298/// The dimensions are checked before any decoder allocates, since
299/// `Limits` enforces only a byte budget and leaves a decoder free to ignore it.
300#[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
319/// Creates an image reader with the configured allocation limit.
320///
321/// The reader is returned before decoding so the caller controls when source
322/// allocation begins.
323fn 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
331/// Creates decoder limits from the configured pixel budget.
332///
333/// Image decoders may allocate four bytes for every source pixel.
334fn 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
342/// Creates an image reader for encoded picture bytes.
343///
344/// The reader infers the source format when recognized. It returns an error
345/// only when probing the bytes fails.
346pub(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
352/// Resizes a decoded picture to the requested dimensions.
353///
354/// Scaling preserves aspect ratio, while cropping fills the requested aspect
355/// ratio within the source bounds. Both methods avoid upscaling.
356pub(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		// upscaling is forbidden outright, and resize_to_fill enlarges a source
370		// smaller than the request to meet it
371		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}