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 = 10_000;
23#[cfg(feature = "multimedia")]
24const MAX_FILE_SIZE: usize = 64 * 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 13333 bytes.
67    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
68        Self {
69            source: PreviewSource::Bytes(bytes.into()),
70            #[cfg(feature = "multimedia")]
71            transcoder: Transcoder::thumbnail(),
72        }
73    }
74
75    /// Thumbnail from a pre-assembled `data:image/jpg;base64,{base64_contents} URI string.
76    pub fn raw(uri: impl Into<String>) -> Self {
77        Self {
78            source: PreviewSource::DataUri(uri.into()),
79            #[cfg(feature = "multimedia")]
80            transcoder: Transcoder::thumbnail(),
81        }
82    }
83
84    /// Thumbnail loaded from a file; the file is read lazily when resolved.
85    pub fn from_file(path: impl AsRef<Path>) -> Self {
86        Self {
87            source: PreviewSource::File(path.as_ref().to_path_buf()),
88            #[cfg(feature = "multimedia")]
89            transcoder: Transcoder::thumbnail(),
90        }
91    }
92
93    pub fn kind(&self) -> PreviewKind {
94        match self.source {
95            PreviewSource::Default => PreviewKind::Default,
96            PreviewSource::Bytes(_) => PreviewKind::Bytes,
97            PreviewSource::DataUri(_) => PreviewKind::Raw,
98            PreviewSource::File(_) => PreviewKind::File,
99            #[cfg(feature = "native_crypto")]
100            PreviewSource::CryptoFile(_) => PreviewKind::CryptoFile,
101        }
102    }
103
104    #[cfg(feature = "native_crypto")]
105    /// Thumbnail loaded from an encrypted file; decrypted lazily when resolved.
106    pub fn from_crypto_file(file: CryptoFile) -> Self {
107        Self {
108            source: PreviewSource::CryptoFile(file),
109            #[cfg(feature = "multimedia")]
110            transcoder: Transcoder::thumbnail(),
111        }
112    }
113
114    #[cfg(feature = "multimedia")]
115    /// Attach a custom [`Transcoder`] to transcode the source as a JPEG thumbnail on resolve.
116    /// Transcoder transcodes images of any widespread format to JPEGs.
117    ///
118    /// Has no effect on `default` and `raw` sources, they always passed as is.
119    pub fn with_transcoder(mut self, transcoder: Transcoder) -> Self {
120        self.set_transcoder(transcoder);
121        self
122    }
123
124    #[cfg(feature = "multimedia")]
125    pub fn set_transcoder(&mut self, transcoder: Transcoder) {
126        self.transcoder = transcoder;
127    }
128
129    /// Like [`Self::try_resolve`] but falls back to the default placeholder preview on error.
130    pub async fn resolve(self) -> String {
131        match self.try_resolve().await {
132            Ok(s) => s,
133            Err(e) => {
134                log::warn!("Falling back to default preview due to an error: {e}");
135                default()
136            }
137        }
138    }
139
140    #[cfg(not(feature = "multimedia"))]
141    /// Returns the preview as a `data:image/jpg;base64,{base64_contents}` URI. The source is
142    /// assumed to be a valid JPEG(encoding is not validated) when multimedia feature is off or is
143    /// lazily transcoded to JPEG when multimedia feature is on. Fails if the source cannot be read
144    /// or the encoded URI exceeds 13333 bytes.
145    pub async fn try_resolve(self) -> Result<String, PreviewError> {
146        match self.source {
147            PreviewSource::Default => Ok(default()),
148            PreviewSource::Bytes(b) => try_encode_jpg_to_uri(&b),
149            PreviewSource::DataUri(s) => validate_uri_preview(s),
150            PreviewSource::File(path) => {
151                let bytes = read_plain_file(&path, MAX_PREVIEW_BYTES).await?;
152                try_encode_jpg_to_uri(&bytes)
153            }
154            #[cfg(feature = "native_crypto")]
155            PreviewSource::CryptoFile(file) => {
156                let bytes = read_crypto_file(file, MAX_PREVIEW_BYTES).await?;
157                try_encode_jpg_to_uri(&bytes)
158            }
159        }
160    }
161
162    #[cfg(feature = "multimedia")]
163    /// Returns the preview as a `data:image/jpg;base64,{base64_contents}` URI. The source is
164    /// assumed to be a valid JPEG(encoding is not validated) when multimedia feature is off or is
165    /// lazily transcoded to JPEG when multimedia feature is on. Fails if the source cannot be read
166    /// or the encoded URI exceeds 13333 bytes.
167    pub async fn try_resolve(self) -> Result<String, PreviewError> {
168        let bytes = match self.source {
169            PreviewSource::Default => return Ok(default()),
170            PreviewSource::Bytes(b) => b,
171            PreviewSource::DataUri(s) => {
172                return validate_uri_preview(s);
173            }
174            PreviewSource::File(path) => read_plain_file(&path, MAX_FILE_SIZE).await?,
175            #[cfg(feature = "native_crypto")]
176            PreviewSource::CryptoFile(file) => read_crypto_file(file, MAX_FILE_SIZE).await?,
177        };
178
179        let jpg_bytes = if self.transcoder.is_enabled() {
180            tokio::task::spawn_blocking(move || -> Result<Vec<u8>, PreviewError> {
181                self.transcoder.transcode_to_jpg(bytes)
182            })
183            .await??
184        } else {
185            bytes
186        };
187
188        try_encode_jpg_to_uri(&jpg_bytes)
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum PreviewKind {
194    Default,
195    Bytes,
196    Raw,
197    File,
198    #[cfg(feature = "native_crypto")]
199    CryptoFile,
200}
201
202#[cfg(feature = "multimedia")]
203pub mod transcoder {
204    use image::{ImageReader, codecs::jpeg::JpegEncoder};
205    use std::io::Cursor;
206
207    use super::PreviewError;
208
209    /// Transcodes images of any wide-spread types to JPEG thumbnails. Default settings generate
210    /// previews similar to SimpleX-Chat previews
211    #[derive(Debug, Clone, Copy)]
212    pub struct Transcoder {
213        enabled: bool,
214        size: (u8, u8),
215        quality: u8,
216        blur: f32,
217    }
218
219    impl Default for Transcoder {
220        fn default() -> Self {
221            Self::jpeg()
222        }
223    }
224
225    impl Transcoder {
226        /// Disable transcoding entirely. Useful for pre-made and pictures thumbnails.
227        pub const fn disabled() -> Self {
228            Self {
229                enabled: false,
230                size: (0, 0),
231                quality: 100,
232                blur: 0.0,
233            }
234        }
235
236        /// Only convert the image to JPEG(the best quality) without applying any other
237        /// transformations. Use builder methods to add transformations on top
238        pub const fn jpeg() -> Self {
239            Self {
240                enabled: true,
241                size: (0, 0),
242                quality: 100,
243                blur: 0.0,
244            }
245        }
246
247        /// The default transcoder for thumbnails. Modify defaults with builder methods.
248        pub const fn thumbnail() -> Self {
249            Self {
250                enabled: true,
251                size: (128, 128),
252                quality: 60,
253                blur: 0.0,
254            }
255        }
256
257        pub const fn is_enabled(&self) -> bool {
258            self.enabled
259        }
260
261        /// Bound between 32x32 and 255x255
262        pub const fn with_size(mut self, x: u8, y: u8) -> Self {
263            let x: u8 = if x < 32 { 32 } else { x };
264            let y: u8 = if y < 32 { 32 } else { y };
265
266            self.size = (x, y);
267            self
268        }
269
270        /// Quality is bound between 1..=100 where 1 is the worst
271        pub const fn with_quality(mut self, quality: u8) -> Self {
272            if quality == 0 {
273                self.quality = 1;
274            } else if quality > 100 {
275                self.quality = 100;
276            } else {
277                self.quality = quality;
278            }
279
280            self
281        }
282
283        /// sigma < 1.0 - no blur. sigma = 100.0 - max blur
284        pub const fn with_blur(mut self, sigma: f32) -> Self {
285            if sigma < 1.0 {
286                self.blur = 0.0;
287            } else if sigma > 100.0 {
288                self.blur = 100.0
289            } else {
290                self.blur = sigma
291            };
292
293            self
294        }
295
296        /// **WARNING**: this is a relatively expensive blocking operation, ensure that you call
297        /// this method outside the tokio executor with `tokio::spawn_blocking` or on a dedicated
298        /// thread.
299        pub fn transcode_to_jpg(self, mut bytes: Vec<u8>) -> Result<Vec<u8>, PreviewError> {
300            if !self.enabled {
301                return Ok(bytes);
302            }
303
304            let img = ImageReader::new(Cursor::new(&bytes))
305                .with_guessed_format()?
306                .decode()?;
307
308            let img = if self.size != (0, 0) {
309                img.thumbnail(self.size.0.into(), self.size.1.into())
310            } else {
311                img
312            };
313
314            let img = if self.blur >= 1.0 {
315                img.fast_blur(self.blur)
316            } else {
317                img
318            };
319
320            bytes.clear();
321            let encoder = JpegEncoder::new_with_quality(&mut bytes, self.quality);
322            img.write_with_encoder(encoder)?;
323
324            Ok(bytes)
325        }
326    }
327}
328
329#[cfg(feature = "multimedia")]
330pub use transcoder::Transcoder;
331
332const URI_HEADER: &str = "data:image/jpg;base64,";
333
334pub fn default() -> String {
335    DEFAULT_PREVIEW.to_owned()
336}
337
338/// Returns the default preview on [`PreviewError`]
339pub fn encode_jpg_to_uri(bytes: &[u8]) -> String {
340    match try_encode_jpg_to_uri(bytes) {
341        Ok(s) => s,
342        Err(e) => {
343            log::warn!("{e}");
344            default()
345        }
346    }
347}
348
349pub fn try_encode_jpg_to_uri(bytes: &[u8]) -> Result<String, PreviewError> {
350    if bytes.len() > MAX_PREVIEW_BYTES {
351        return Err(PreviewError::TooLarge);
352    }
353
354    let mut encoded = String::with_capacity(bytes.len() * 4 / 3 + URI_HEADER.len() + 3);
355    encoded.push_str(URI_HEADER);
356    BASE64_STANDARD.encode_string(bytes, &mut encoded);
357
358    Ok(encoded)
359}
360
361pub fn try_decode_jpg_from_uri(uri_str: &str) -> Result<Vec<u8>, UriDecodeError> {
362    let Some(s) = uri_str.strip_prefix(URI_HEADER) else {
363        return Err(UriDecodeError::NotAUri);
364    };
365
366    BASE64_STANDARD.decode(s).map_err(UriDecodeError::Base64)
367}
368
369#[derive(Debug)]
370pub enum PreviewError {
371    TooLarge,
372    BadUri(UriDecodeError),
373    Io(std::io::Error),
374    #[cfg(feature = "multimedia")]
375    Transcoding(image::ImageError),
376    #[cfg(feature = "multimedia")]
377    Tokio(tokio::task::JoinError),
378}
379
380impl From<std::io::Error> for PreviewError {
381    fn from(err: std::io::Error) -> Self {
382        Self::Io(err)
383    }
384}
385
386#[cfg(feature = "multimedia")]
387impl From<image::ImageError> for PreviewError {
388    fn from(err: image::ImageError) -> Self {
389        Self::Transcoding(err)
390    }
391}
392
393#[cfg(feature = "multimedia")]
394impl From<tokio::task::JoinError> for PreviewError {
395    fn from(err: tokio::task::JoinError) -> Self {
396        Self::Tokio(err)
397    }
398}
399
400impl std::fmt::Display for PreviewError {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        match self {
403            Self::TooLarge => {
404                write!(
405                    f,
406                    "preview size exceeds the max possible size({MAX_PREVIEW_BYTES} bytes)"
407                )
408            }
409            Self::BadUri(e) => write!(f, "{e}"),
410            Self::Io(error) => write!(f, "Cannot process preview file: {error}"),
411            #[cfg(feature = "multimedia")]
412            Self::Transcoding(error) => write!(f, "Cannot transcode preview: {error}"),
413            #[cfg(feature = "multimedia")]
414            Self::Tokio(error) => write!(f, "Failed to join the transcoding task: {error}"),
415        }
416    }
417}
418
419impl std::error::Error for PreviewError {
420    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
421        match self {
422            Self::TooLarge => None,
423            Self::BadUri(error) => Some(error),
424            Self::Io(error) => Some(error),
425            #[cfg(feature = "multimedia")]
426            Self::Transcoding(error) => Some(error),
427            #[cfg(feature = "multimedia")]
428            Self::Tokio(error) => Some(error),
429        }
430    }
431}
432
433#[derive(Debug)]
434pub enum UriDecodeError {
435    NotAUri,
436    Base64(base64::DecodeError),
437}
438
439impl std::fmt::Display for UriDecodeError {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        match self {
442            Self::NotAUri => write!(f, "not a URI string"),
443            Self::Base64(e) => write!(f, "{e}"),
444        }
445    }
446}
447
448impl std::error::Error for UriDecodeError {
449    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
450        if let Self::Base64(e) = self {
451            Some(e)
452        } else {
453            None
454        }
455    }
456}
457
458#[derive(Clone)]
459enum PreviewSource {
460    Default,
461    Bytes(Vec<u8>),
462    DataUri(String),
463    File(PathBuf),
464    #[cfg(feature = "native_crypto")]
465    CryptoFile(CryptoFile),
466}
467
468async fn read_plain_file(path: &PathBuf, size_limit: usize) -> std::io::Result<Vec<u8>> {
469    let mut f = tokio::fs::File::open(&path).await?;
470    let size_hint = f.seek(SeekFrom::End(0)).await?;
471    f.seek(SeekFrom::Start(0)).await?;
472    let size_hint: usize = util::cast_file_size(size_hint)?;
473
474    if size_hint > size_limit {
475        return Err(util::file_is_too_large(format!(
476            "Size exceeds {size_limit} bytes"
477        )));
478    }
479
480    let mut buf = Vec::with_capacity(size_hint);
481    f.read_to_end(&mut buf).await?;
482
483    Ok(buf)
484}
485
486#[cfg(feature = "native_crypto")]
487async fn read_crypto_file(file: CryptoFile, size_limit: usize) -> std::io::Result<Vec<u8>> {
488    let mut f = crate::crypto::fs::TokioMaybeCryptoFile::from_crypto_file(file).await?;
489    let size_hint = f.size_hint().await?;
490
491    if size_hint > size_limit {
492        return Err(util::file_is_too_large(format!(
493            "Size exceeds {size_limit} bytes"
494        )));
495    }
496
497    let mut buf = Vec::with_capacity(size_hint);
498    f.read_to_end(&mut buf).await?;
499
500    Ok(buf)
501}
502
503fn validate_uri_preview(uri: String) -> Result<String, PreviewError> {
504    let Some(s) = uri.strip_prefix(URI_HEADER) else {
505        return Err(PreviewError::BadUri(UriDecodeError::NotAUri));
506    };
507
508    if s.len() > MAX_PREVIEW_BYTES * 4 / 3 {
509        return Err(PreviewError::TooLarge);
510    }
511
512    Ok(uri)
513}