Skip to main content

simploxide_client/
preview.rs

1//! Image previews generation
2
3use base64::prelude::*;
4#[cfg(feature = "native_crypto")]
5use simploxide_api_types::CryptoFile;
6use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _};
7
8use crate::util;
9
10use std::{
11    io::SeekFrom,
12    path::{Path, PathBuf},
13};
14
15const DEFAULT_PREVIEW: &str = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/\
162wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/\
172wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAARCABVAIADASIAAhEBAxEB/\
188QAFgABAQEAAAAAAAAAAAAAAAAAAAEE/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/EABgBAQEBAQEAAAAAAAAAAAAAAAMCAQUE/8QAFhEBAQEAAAAAAAAAAAAAAAAAAAER/\
199oADAMBAAIRAxEAPwDaKF17qgo3UVBRWjqCjdFUFFaOoKN0VQUVo6go3R0FHi13ago3R1BRWjoijdHUFFSiqCjZR1BRUo6go3RVQHh13aAK1FAVuiqCipR1BRUo6go2UV\
20QUVKOoKK0dAHg13aCjdHUFFaOoKKlHUFFSiqCipR1BRsoqgoqUdBR4Nd2oKNlHUUFSjoAqUdAFSjoAqUVAFSjoAqUdAHPd2gCoOqAqDoA2DoAuCoAqDoAqCoAqDr//2Q==";
21
22const MAX_PREVIEW_BYTES: usize = 9350;
23#[cfg(feature = "multimedia")]
24const MAX_FILE_SIZE: usize = 20 * 1024 * 1024;
25
26/// Thumbnail for [`Image`](crate::messages::Image), [`Video`](crate::messages::Video), and
27/// [`Link`](crate::messages::Link) messages. Also used as bot profile pictures. The source is stored
28/// lazily and resolved when [`resolve`](Self::resolve) or [`try_resolve`](Self::try_resolve) is
29/// called(either manually or automatically by message builders). Any error falls back to a default
30/// ~600 bytes in size JPEG placeholder.
31#[derive(Clone)]
32pub struct ImagePreview {
33    source: PreviewSource,
34    #[cfg(feature = "multimedia")]
35    transcoder: Transcoder,
36}
37
38impl Default for ImagePreview {
39    fn default() -> Self {
40        Self {
41            source: PreviewSource::Default,
42            #[cfg(feature = "multimedia")]
43            transcoder: Transcoder::thumbnail(),
44        }
45    }
46}
47
48impl std::fmt::Debug for ImagePreview {
49    #[cfg(not(feature = "multimedia"))]
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("ImagePreview")
52            .field("source", &self.kind())
53            .finish()
54    }
55
56    #[cfg(feature = "multimedia")]
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("ImagePreview")
59            .field("source", &self.kind())
60            .field("transcoder", &self.transcoder)
61            .finish()
62    }
63}
64
65impl ImagePreview {
66    /// Thumbnail from raw JPEG bytes. Fails on resolve if the encoded data URI exceeds
67    /// [`MAX_PREVIEW_BYTES`].
68    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
69        Self {
70            source: PreviewSource::Bytes(bytes.into()),
71            #[cfg(feature = "multimedia")]
72            transcoder: Transcoder::thumbnail(),
73        }
74    }
75
76    /// Thumbnail from a pre-assembled `data:image/jpg;base64,{base64_contents} URI string.
77    pub fn raw(uri: impl Into<String>) -> Self {
78        Self {
79            source: PreviewSource::DataUri(uri.into()),
80            #[cfg(feature = "multimedia")]
81            transcoder: Transcoder::thumbnail(),
82        }
83    }
84
85    /// Thumbnail loaded from a file; the file is read lazily when resolved.
86    pub fn from_file(path: impl AsRef<Path>) -> Self {
87        Self {
88            source: PreviewSource::File(path.as_ref().to_path_buf()),
89            #[cfg(feature = "multimedia")]
90            transcoder: Transcoder::thumbnail(),
91        }
92    }
93
94    pub fn kind(&self) -> PreviewKind {
95        match self.source {
96            PreviewSource::Default => PreviewKind::Default,
97            PreviewSource::Bytes(_) => PreviewKind::Bytes,
98            PreviewSource::DataUri(_) => PreviewKind::Raw,
99            PreviewSource::File(_) => PreviewKind::File,
100            #[cfg(feature = "native_crypto")]
101            PreviewSource::CryptoFile(_) => PreviewKind::CryptoFile,
102        }
103    }
104
105    #[cfg(feature = "native_crypto")]
106    /// Thumbnail loaded from an encrypted file; decrypted lazily when resolved.
107    pub fn from_crypto_file(file: CryptoFile) -> Self {
108        Self {
109            source: PreviewSource::CryptoFile(file),
110            #[cfg(feature = "multimedia")]
111            transcoder: Transcoder::thumbnail(),
112        }
113    }
114
115    #[cfg(feature = "multimedia")]
116    /// Attach a custom [`Transcoder`] to transcode the source as a JPEG thumbnail on resolve.
117    /// Transcoder transcodes images of any widespread format to JPEGs.
118    ///
119    /// Has no effect on `default` and `raw` sources, they always passed as is.
120    pub fn with_transcoder(mut self, transcoder: Transcoder) -> Self {
121        self.set_transcoder(transcoder);
122        self
123    }
124
125    #[cfg(feature = "multimedia")]
126    pub fn set_transcoder(&mut self, transcoder: Transcoder) {
127        self.transcoder = transcoder;
128    }
129
130    /// Like [`Self::try_resolve`] but falls back to the default placeholder preview on error.
131    pub async fn resolve(self) -> String {
132        match self.try_resolve().await {
133            Ok(s) => s,
134            Err(e) => {
135                log::warn!("Falling back to default preview due to an error: {e}");
136                default()
137            }
138        }
139    }
140
141    #[cfg(not(feature = "multimedia"))]
142    /// Returns the preview as a `data:image/jpg;base64,{base64_contents}` URI. The source is
143    /// assumed to be a valid JPEG(encoding is not validated) when multimedia feature is disabled or it is
144    /// lazily transcoded into JPEG when multimedia is enabled. Fails if the source cannot be read
145    /// or the encoded URI exceeds [`MAX_PREVIEW_BYTES`] bytes.
146    pub async fn try_resolve(self) -> Result<String, PreviewError> {
147        match self.source {
148            PreviewSource::Default => Ok(default()),
149            PreviewSource::Bytes(b) => try_encode_jpg_to_uri(&b),
150            PreviewSource::DataUri(s) => validate_uri_preview(s),
151            PreviewSource::File(path) => {
152                let bytes = read_plain_file(&path, MAX_PREVIEW_BYTES).await?;
153                try_encode_jpg_to_uri(&bytes)
154            }
155            #[cfg(feature = "native_crypto")]
156            PreviewSource::CryptoFile(file) => {
157                let bytes = read_crypto_file(file, MAX_PREVIEW_BYTES).await?;
158                try_encode_jpg_to_uri(&bytes)
159            }
160        }
161    }
162
163    #[cfg(feature = "multimedia")]
164    /// Returns the preview as a `data:image/jpg;base64,{base64_contents}` URI. The source is
165    /// assumed to be a valid JPEG(encoding is not validated) when multimedia feature is disabled or it is
166    /// lazily transcoded into JPEG when multimedia is enabled. Fails if the source cannot be read
167    /// or the encoded URI exceeds [`MAX_PREVIEW_BYTES`] bytes.
168    pub async fn try_resolve(self) -> Result<String, PreviewError> {
169        let bytes = match self.source {
170            PreviewSource::Default => return Ok(default()),
171            PreviewSource::Bytes(b) => b,
172            PreviewSource::DataUri(s) => return validate_uri_preview(s),
173            PreviewSource::File(path) => read_plain_file(&path, MAX_FILE_SIZE).await?,
174            #[cfg(feature = "native_crypto")]
175            PreviewSource::CryptoFile(file) => read_crypto_file(file, MAX_FILE_SIZE).await?,
176        };
177
178        if self.transcoder.is_enabled() {
179            let jpg_bytes =
180                tokio::task::spawn_blocking(move || self.transcoder.transcode_to_jpg(bytes))
181                    .await??;
182            Ok(encode_to_uri(&jpg_bytes))
183        } else {
184            try_encode_jpg_to_uri(&bytes)
185        }
186    }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum PreviewKind {
191    Default,
192    Bytes,
193    Raw,
194    File,
195    #[cfg(feature = "native_crypto")]
196    CryptoFile,
197}
198
199#[cfg(feature = "multimedia")]
200pub mod transcoder {
201    use image::{
202        ImageFormat, ImageReader,
203        codecs::jpeg::JpegEncoder,
204        imageops::{self, FilterType},
205    };
206    use std::io::Cursor;
207
208    use super::PreviewError;
209
210    const THUMBNAIL_MAX_BYTES: usize = 10450;
211    const AVATAR_MAX_BYTES: usize = 9350;
212
213    const THUMBNAIL_SIZES: [u16; 4] = [256, 192, 128, 96];
214    const AVATAR_SIZES: [u16; 4] = [160, 128, 96, 64];
215    const PREVIEW_QUALITIES: [u8; 4] = [85, 75, 60, 45];
216
217    #[derive(Debug, Clone, Copy)]
218    pub struct Transcoder {
219        sizes: [u16; 4],
220        max_bytes: usize,
221    }
222
223    impl Default for Transcoder {
224        fn default() -> Self {
225            Self::thumbnail()
226        }
227    }
228
229    impl Transcoder {
230        pub const fn disabled() -> Self {
231            Self {
232                sizes: [0; 4],
233                max_bytes: 0,
234            }
235        }
236
237        pub const fn thumbnail() -> Self {
238            Self {
239                sizes: THUMBNAIL_SIZES,
240                max_bytes: THUMBNAIL_MAX_BYTES,
241            }
242        }
243
244        pub const fn avatar() -> Self {
245            Self {
246                sizes: AVATAR_SIZES,
247                max_bytes: AVATAR_MAX_BYTES,
248            }
249        }
250
251        pub const fn is_enabled(&self) -> bool {
252            !self.is_disabled()
253        }
254
255        pub const fn is_disabled(&self) -> bool {
256            self.max_bytes == 0 || self.sizes[0] == 0
257        }
258
259        pub const fn with_sizes(mut self, sizes: [u16; 4]) -> Self {
260            self.sizes = sizes;
261            self
262        }
263
264        pub const fn with_max_bytes(mut self, max_bytes: usize) -> Self {
265            self.max_bytes = max_bytes;
266            self
267        }
268
269        pub fn transcode_to_jpg(self, mut bytes: Vec<u8>) -> Result<Vec<u8>, PreviewError> {
270            if !self.is_enabled() {
271                return Ok(bytes);
272            }
273
274            let reader = ImageReader::new(Cursor::new(&bytes)).with_guessed_format()?;
275
276            if reader.format() == Some(ImageFormat::Jpeg) && bytes.len() <= self.max_bytes {
277                return Ok(bytes);
278            }
279
280            let img = reader.decode()?;
281
282            let (orig_w, orig_h) = (img.width(), img.height());
283            let max_orig = orig_w.max(orig_h);
284
285            let mut last_effective = u32::MAX;
286            for &size in &self.sizes {
287                // Never upscale: images smaller than this slot use their natural dimensions.
288                let effective = (size as u32).min(max_orig);
289                if effective == last_effective {
290                    continue;
291                }
292                last_effective = effective;
293
294                let rgb = if effective < max_orig {
295                    let resized = img.resize(effective, effective, FilterType::Lanczos3);
296                    imageops::unsharpen(&resized.to_rgb8(), 0.5, 0)
297                } else {
298                    img.to_rgb8()
299                };
300
301                for &quality in &PREVIEW_QUALITIES {
302                    bytes.clear();
303                    JpegEncoder::new_with_quality(&mut bytes, quality).encode_image(&rgb)?;
304
305                    if bytes.len() <= self.max_bytes {
306                        return Ok(bytes);
307                    }
308                }
309            }
310
311            Err(PreviewError::TooLarge)
312        }
313    }
314}
315
316#[cfg(feature = "multimedia")]
317pub use transcoder::Transcoder;
318
319const URI_HEADER: &str = "data:image/jpg;base64,";
320
321pub fn default() -> String {
322    DEFAULT_PREVIEW.to_owned()
323}
324
325/// Returns the default preview on [`PreviewError`]
326pub fn encode_jpg_to_uri(bytes: &[u8]) -> String {
327    match try_encode_jpg_to_uri(bytes) {
328        Ok(s) => s,
329        Err(e) => {
330            log::warn!("{e}");
331            default()
332        }
333    }
334}
335
336pub fn try_encode_jpg_to_uri(bytes: &[u8]) -> Result<String, PreviewError> {
337    if bytes.len() > MAX_PREVIEW_BYTES {
338        return Err(PreviewError::TooLarge);
339    }
340    Ok(encode_to_uri(bytes))
341}
342
343fn encode_to_uri(bytes: &[u8]) -> String {
344    let mut encoded = String::with_capacity(bytes.len() * 4 / 3 + URI_HEADER.len() + 4);
345    encoded.push_str(URI_HEADER);
346    BASE64_STANDARD.encode_string(bytes, &mut encoded);
347    encoded
348}
349
350pub fn try_decode_jpg_from_uri(uri_str: &str) -> Result<Vec<u8>, UriDecodeError> {
351    let Some(s) = uri_str.strip_prefix(URI_HEADER) else {
352        return Err(UriDecodeError::NotAUri);
353    };
354
355    BASE64_STANDARD.decode(s).map_err(UriDecodeError::Base64)
356}
357
358#[derive(Debug)]
359pub enum PreviewError {
360    TooLarge,
361    BadUri(UriDecodeError),
362    Io(std::io::Error),
363    #[cfg(feature = "multimedia")]
364    Transcoding(image::ImageError),
365    #[cfg(feature = "multimedia")]
366    Tokio(tokio::task::JoinError),
367}
368
369impl From<std::io::Error> for PreviewError {
370    fn from(err: std::io::Error) -> Self {
371        Self::Io(err)
372    }
373}
374
375#[cfg(feature = "multimedia")]
376impl From<image::ImageError> for PreviewError {
377    fn from(err: image::ImageError) -> Self {
378        Self::Transcoding(err)
379    }
380}
381
382#[cfg(feature = "multimedia")]
383impl From<tokio::task::JoinError> for PreviewError {
384    fn from(err: tokio::task::JoinError) -> Self {
385        Self::Tokio(err)
386    }
387}
388
389impl std::fmt::Display for PreviewError {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        match self {
392            Self::TooLarge => {
393                write!(
394                    f,
395                    "preview size exceeds the max possible size({MAX_PREVIEW_BYTES} bytes)"
396                )
397            }
398            Self::BadUri(e) => write!(f, "{e}"),
399            Self::Io(error) => write!(f, "Cannot process preview file: {error}"),
400            #[cfg(feature = "multimedia")]
401            Self::Transcoding(error) => write!(f, "Cannot transcode preview: {error}"),
402            #[cfg(feature = "multimedia")]
403            Self::Tokio(error) => write!(f, "Failed to join the transcoding task: {error}"),
404        }
405    }
406}
407
408impl std::error::Error for PreviewError {
409    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
410        match self {
411            Self::TooLarge => None,
412            Self::BadUri(error) => Some(error),
413            Self::Io(error) => Some(error),
414            #[cfg(feature = "multimedia")]
415            Self::Transcoding(error) => Some(error),
416            #[cfg(feature = "multimedia")]
417            Self::Tokio(error) => Some(error),
418        }
419    }
420}
421
422#[derive(Debug)]
423pub enum UriDecodeError {
424    NotAUri,
425    Base64(base64::DecodeError),
426}
427
428impl std::fmt::Display for UriDecodeError {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        match self {
431            Self::NotAUri => write!(f, "not a URI string"),
432            Self::Base64(e) => write!(f, "{e}"),
433        }
434    }
435}
436
437impl std::error::Error for UriDecodeError {
438    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
439        if let Self::Base64(e) = self {
440            Some(e)
441        } else {
442            None
443        }
444    }
445}
446
447#[derive(Clone)]
448enum PreviewSource {
449    Default,
450    Bytes(Vec<u8>),
451    DataUri(String),
452    File(PathBuf),
453    #[cfg(feature = "native_crypto")]
454    CryptoFile(CryptoFile),
455}
456
457async fn read_plain_file(path: &PathBuf, size_limit: usize) -> std::io::Result<Vec<u8>> {
458    let mut f = tokio::fs::File::open(&path).await?;
459    let size_hint = f.seek(SeekFrom::End(0)).await?;
460    f.seek(SeekFrom::Start(0)).await?;
461    let size_hint: usize = util::cast_file_size(size_hint)?;
462
463    if size_hint > size_limit {
464        return Err(util::file_is_too_large(format!(
465            "Size exceeds {size_limit} bytes"
466        )));
467    }
468
469    let mut buf = Vec::with_capacity(size_hint);
470    f.read_to_end(&mut buf).await?;
471
472    Ok(buf)
473}
474
475#[cfg(feature = "native_crypto")]
476async fn read_crypto_file(file: CryptoFile, size_limit: usize) -> std::io::Result<Vec<u8>> {
477    let mut f = crate::crypto::fs::TokioMaybeCryptoFile::from_crypto_file(file).await?;
478    let size_hint = f.size_hint().await?;
479
480    if size_hint > size_limit {
481        return Err(util::file_is_too_large(format!(
482            "Size exceeds {size_limit} bytes"
483        )));
484    }
485
486    let mut buf = Vec::with_capacity(size_hint);
487    f.read_to_end(&mut buf).await?;
488
489    Ok(buf)
490}
491
492fn validate_uri_preview(uri: String) -> Result<String, PreviewError> {
493    let Some(s) = uri.strip_prefix(URI_HEADER) else {
494        return Err(PreviewError::BadUri(UriDecodeError::NotAUri));
495    };
496
497    if s.len() > MAX_PREVIEW_BYTES * 4 / 3 {
498        return Err(PreviewError::TooLarge);
499    }
500
501    Ok(uri)
502}