Skip to main content

tuwunel_service/media/thumbnail/
encode.rs

1//! Encodes animated thumbnails.
2//!
3//! A picture that carries a frame sequence can answer a request for an animated
4//! thumbnail with the frames themselves rather than one of them. The three
5//! containers MSC2705 names each hold a sequence differently, so each has its
6//! own decoder here, and all of them produce a GIF, which is the one animated
7//! format this build can encode.
8//!
9//! The caps that bound the work live here rather than at either call site, so
10//! a source reaches no decoder before what it would cost is known.
11
12use std::{
13	cmp::min,
14	io::Cursor,
15	panic::{AssertUnwindSafe, catch_unwind},
16	result::Result as StdResult,
17};
18
19use bytes::Bytes;
20use image::{
21	AnimationDecoder, DynamicImage, Frame, Frames, ImageDecoder, ImageFormat, Limits,
22	codecs::{
23		gif::{GifDecoder, GifEncoder, Repeat},
24		png::PngDecoder,
25		webp::WebPDecoder,
26	},
27};
28use ruma::Mxc;
29use tokio::sync::OwnedSemaphorePermit;
30use tuwunel_core::{Err, Error, Result, defer, err, implement};
31
32use super::{
33	super::Media,
34	Dim,
35	animate::GIF,
36	generate::{BYTES_PER_PIXEL, reader, thumbnail_generate},
37};
38
39const ANIMATED_NAME: &str = "thumbnail.gif";
40
41/// Quantization effort the GIF encoder spends per frame.
42///
43/// The encoder's own default is 1, which buys quality at any cost; a thumbnail
44/// is small and any remote server can ask for one, so this trades a little of
45/// that for an encode that ends.
46const GIF_SPEED: i32 = 10;
47
48pub(super) struct Output {
49	pub(super) media: Result<Media>,
50	pub(super) admission: OwnedSemaphorePermit,
51	pub(super) source: Bytes,
52}
53
54/// Encode this picture's frames as an animated GIF thumbnail and store it.
55///
56/// Quantization runs per frame on a palette of its own, which is far more work
57/// than a still costs, so the encode is handed to a blocking worker rather than
58/// run on the async runtime. A source whose frames will not decode answers an
59/// error and the caller falls through to the still it would have produced.
60#[implement(super::super::Service)]
61#[tracing::instrument(
62	name = "animate",
63	level = "debug",
64	skip(self, source, admission)
65)]
66pub(super) async fn store_animated(
67	&self,
68	mxc: &Mxc<'_>,
69	dim: &Dim,
70	source: Bytes,
71	admission: OwnedSemaphorePermit,
72) -> Result<Output> {
73	let config = &self.services.config;
74	let max_frames = config.media_thumbnail_max_frames;
75	let budget = config.media_thumbnail_max_pixels;
76	let requested = Dim::new(dim.width, dim.height, Some(dim.method.clone()));
77
78	#[cfg(test)]
79	super::tests::before_animation_submit().await;
80	#[cfg(test)]
81	let control = super::tests::animation_control();
82
83	let encode = self
84		.services
85		.server
86		.runtime()
87		.spawn_blocking(move || {
88			#[cfg(test)]
89			if let Some(control) = control.as_ref() {
90				control.worker_started();
91			}
92
93			let content = catch_unwind(AssertUnwindSafe(|| {
94				encode_frames(&source, &requested, max_frames, budget)
95			}))
96			.map_err(Error::from_panic)
97			.unwrap_or_else(Err);
98
99			#[cfg(test)]
100			if let Some(control) = control.as_ref() {
101				control.worker_finished();
102			}
103
104			(content, admission, source)
105		});
106
107	#[cfg(test)]
108	super::tests::animation_submitted();
109
110	// a started worker runs to completion, but one still queued is dropped, so
111	// a cancelled request must not leave it to reach the pool
112	let abort = encode.abort_handle();
113
114	defer! {{ abort.abort(); }}
115
116	let (content, admission, source) = encode.await?;
117	let media = match content {
118		| Err(error) => Err(error),
119		| Ok(content) =>
120			self.store_encoded(mxc, dim, content, GIF, ANIMATED_NAME)
121				.await,
122	};
123
124	Ok(Output { media, admission, source })
125}
126
127/// Encode a picture's frames as a GIF scaled to these dimensions.
128///
129/// Both caps are settled before a frame is pulled, so neither the decode nor
130/// the encode can run past them: the sequence stops at the frame count or at
131/// what the pixel budget affords, and the animation loops short rather than the
132/// request failing. Fewer than two frames is no animation and answers an error.
133pub(in super::super) fn encode_frames(
134	bytes: &[u8],
135	dim: &Dim,
136	max_frames: usize,
137	budget: u64,
138) -> Result<Vec<u8>> {
139	let (frames, canvas) = source_frames(bytes, budget)?;
140
141	// every frame composites onto the whole canvas whatever region it updates,
142	// so what the budget affords is a count rather than a running total
143	let afforded = budget
144		.checked_div(canvas)
145		.and_then(|afforded| usize::try_from(afforded).ok())
146		.unwrap_or(max_frames);
147
148	let limit = min(max_frames, afforded);
149	let (content, count) = encode_sequence(frames, dim, limit)?;
150
151	if count < 2 {
152		return Err!(debug_warn!(%count, "Picture carries no frame sequence."));
153	}
154
155	Ok(content)
156}
157
158/// Encodes a bounded frame sequence into one GIF buffer.
159///
160/// A decode failure ends the sequence where it stands. Encoding failures still
161/// abort the result because the output buffer would be incomplete.
162fn encode_sequence(frames: Frames<'_>, dim: &Dim, limit: usize) -> Result<(Vec<u8>, usize)> {
163	let mut content = Vec::new();
164	let count = {
165		let mut encoder = GifEncoder::new_with_speed(&mut content, GIF_SPEED);
166
167		encoder
168			.set_repeat(Repeat::Infinite)
169			.map_err(|error| err!(debug_warn!(?error, "Failed to set the GIF loop count.")))?;
170
171		let count = frames
172			.take(limit)
173			.map_while(StdResult::ok)
174			.try_fold(0_usize, |count, frame| -> Result<usize> {
175				let delay = frame.delay();
176				let image = DynamicImage::ImageRgba8(frame.into_buffer());
177				let scaled = thumbnail_generate(&image, dim)?;
178
179				drop(image);
180				encoder
181					.encode_frame(Frame::from_parts(scaled.into_rgba8(), 0, 0, delay))
182					.map_err(|error| err!(debug_warn!(?error, "Failed to encode a frame.")))?;
183
184				Ok(count.saturating_add(1))
185			})?;
186
187		// the encoder writes the trailer as it goes out of scope, so the picture
188		// is not complete until it has
189		drop(encoder);
190
191		count
192	};
193
194	Ok((content, count))
195}
196
197/// The frame sequence a picture carries, and what one frame of it costs.
198///
199/// Only the three containers MSC2705 names can hold a sequence, and two of them
200/// say in their own header whether they do, so a still answers an error here
201/// rather than yielding a lone frame for the caller to reject. Each decoder is
202/// built without limits of its own and composites a whole source canvas on its
203/// first advance, so the canvas is budgeted and the decoder told what it may
204/// allocate before it is ever advanced.
205fn source_frames(bytes: &[u8], budget: u64) -> Result<(Frames<'_>, u64)> {
206	let format = reader(bytes)?
207		.format()
208		.ok_or_else(|| err!(debug_warn!("Picture names no format.")))?;
209
210	let source = Cursor::new(bytes);
211
212	match format {
213		| ImageFormat::Gif => gif_frames(source, budget),
214		| ImageFormat::Png => png_frames(source, budget),
215		| ImageFormat::WebP => webp_frames(source, budget),
216		| _ => Err!(debug_warn!(?format, "Format carries no frame sequence.")),
217	}
218}
219
220/// Builds a bounded GIF frame iterator.
221///
222/// The canvas is checked before limits are installed or the decoder advances.
223fn gif_frames(source: Cursor<&[u8]>, budget: u64) -> Result<(Frames<'_>, u64)> {
224	let format = ImageFormat::Gif;
225	let failed = |error| err!(debug_warn!(?error, ?format, "Failed to read a frame sequence."));
226	let mut decoder = GifDecoder::new(source).map_err(failed)?;
227	let canvas = canvas_pixels(&decoder, budget)?;
228
229	decoder
230		.set_limits(decoder_limits(budget))
231		.map_err(failed)?;
232
233	Ok((decoder.into_frames(), canvas))
234}
235
236/// Builds a bounded APNG frame iterator.
237///
238/// A regular PNG is rejected before the decoder advances.
239fn png_frames(source: Cursor<&[u8]>, budget: u64) -> Result<(Frames<'_>, u64)> {
240	let format = ImageFormat::Png;
241	let failed = |error| err!(debug_warn!(?error, ?format, "Failed to read a frame sequence."));
242	let mut decoder = PngDecoder::new(source).map_err(failed)?;
243	let canvas = canvas_pixels(&decoder, budget)?;
244
245	if !decoder.is_apng().map_err(failed)? {
246		return Err!(debug_warn!("PNG carries a single frame."));
247	}
248
249	decoder
250		.set_limits(decoder_limits(budget))
251		.map_err(failed)?;
252
253	Ok((decoder.apng().map_err(failed)?.into_frames(), canvas))
254}
255
256/// Builds a bounded WebP frame iterator.
257///
258/// A still WebP is rejected before the decoder advances.
259fn webp_frames(source: Cursor<&[u8]>, budget: u64) -> Result<(Frames<'_>, u64)> {
260	let format = ImageFormat::WebP;
261	let failed = |error| err!(debug_warn!(?error, ?format, "Failed to read a frame sequence."));
262	let mut decoder = WebPDecoder::new(source).map_err(failed)?;
263	let canvas = canvas_pixels(&decoder, budget)?;
264
265	if !decoder.has_animation() {
266		return Err!(debug_warn!("WebP carries a single frame."));
267	}
268
269	decoder
270		.set_limits(decoder_limits(budget))
271		.map_err(failed)?;
272
273	Ok((decoder.into_frames(), canvas))
274}
275
276/// Creates decoder limits from the configured pixel budget.
277///
278/// Decoders may allocate four bytes for every pixel they composite.
279fn decoder_limits(budget: u64) -> Limits {
280	let mut limits = Limits::no_limits();
281
282	limits.max_alloc = Some(budget.saturating_mul(BYTES_PER_PIXEL));
283
284	limits
285}
286
287/// Pixels one composited frame of this source occupies.
288///
289/// A canvas the budget cannot afford even once is refused here, which is before
290/// the decoder has been advanced onto it and so before it has allocated one.
291fn canvas_pixels(decoder: &impl ImageDecoder, budget: u64) -> Result<u64> {
292	let (width, height) = decoder.dimensions();
293	let pixels = u64::from(width).saturating_mul(u64::from(height));
294
295	if pixels == 0 || pixels > budget {
296		return Err!(
297			debug_warn!(%width, %height, %budget, "Canvas is outside the pixel budget.")
298		);
299	}
300
301	Ok(pixels)
302}