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::default(),
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::default(),
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::default(),
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::default(),
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::default(),
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 {
222                enabled: true,
223                size: (128, 128),
224                quality: 60,
225                blur: 0.0,
226            }
227        }
228    }
229
230    impl Transcoder {
231        /// Disable transcoding. Useful for pre-made thumbnails.
232        pub fn disabled() -> Self {
233            Self {
234                enabled: false,
235                ..Default::default()
236            }
237        }
238
239        pub fn is_enabled(&self) -> bool {
240            self.enabled
241        }
242
243        /// Bound between 32x32 and 255x255
244        pub fn with_size(mut self, x: u8, y: u8) -> Self {
245            let x = std::cmp::max(32, x);
246            let y = std::cmp::max(32, y);
247
248            self.size = (x, y);
249
250            self
251        }
252
253        /// Quality is bound between 1..=100 where 1 is the worst
254        pub fn with_quality(mut self, quality: u8) -> Self {
255            if quality == 0 {
256                self.quality = 1;
257            } else if quality > 100 {
258                self.quality = 100;
259            } else {
260                self.quality = quality;
261            }
262
263            self
264        }
265
266        /// sigma < 1.0 - no blur. sigma = 100.0 - max blur
267        pub fn with_blur(mut self, sigma: f32) -> Self {
268            if sigma < 1.0 {
269                self.blur = 0.0;
270            } else if sigma > 100.0 {
271                self.blur = 100.0
272            } else {
273                self.blur = sigma
274            };
275
276            self
277        }
278
279        /// **WARNING**: this is a relatively expensive blocking operation, ensure that you call
280        /// this method outside the tokio executor with `tokio::spawn_blocking` or on a dedicated
281        /// thread.
282        pub fn transcode_to_jpg(self, mut bytes: Vec<u8>) -> Result<Vec<u8>, PreviewError> {
283            if !self.enabled {
284                return Ok(bytes);
285            }
286
287            let img = ImageReader::new(Cursor::new(&bytes))
288                .with_guessed_format()?
289                .decode()?;
290
291            let img = img.thumbnail(self.size.0.into(), self.size.1.into());
292
293            let img = if self.blur >= 1.0 {
294                img.fast_blur(self.blur)
295            } else {
296                img
297            };
298
299            bytes.clear();
300            let encoder = JpegEncoder::new_with_quality(&mut bytes, self.quality);
301            img.write_with_encoder(encoder)?;
302
303            Ok(bytes)
304        }
305    }
306}
307
308#[cfg(feature = "multimedia")]
309pub use transcoder::Transcoder;
310
311const URI_HEADER: &str = "data:image/jpg;base64,";
312
313pub fn default() -> String {
314    DEFAULT_PREVIEW.to_owned()
315}
316
317/// Returns the default preview on [`PreviewError`]
318pub fn encode_jpg_to_uri(bytes: &[u8]) -> String {
319    match try_encode_jpg_to_uri(bytes) {
320        Ok(s) => s,
321        Err(e) => {
322            log::warn!("{e}");
323            default()
324        }
325    }
326}
327
328pub fn try_encode_jpg_to_uri(bytes: &[u8]) -> Result<String, PreviewError> {
329    if bytes.len() > MAX_PREVIEW_BYTES {
330        return Err(PreviewError::TooLarge);
331    }
332
333    let mut encoded = String::with_capacity(bytes.len() * 4 / 3 + URI_HEADER.len() + 3);
334    encoded.push_str(URI_HEADER);
335    BASE64_STANDARD.encode_string(bytes, &mut encoded);
336
337    Ok(encoded)
338}
339
340pub fn try_decode_jpg_from_uri(uri_str: &str) -> Result<Vec<u8>, UriDecodeError> {
341    let Some(s) = uri_str.strip_prefix(URI_HEADER) else {
342        return Err(UriDecodeError::NotAUri);
343    };
344
345    BASE64_STANDARD.decode(s).map_err(UriDecodeError::Base64)
346}
347
348#[derive(Debug)]
349pub enum PreviewError {
350    TooLarge,
351    BadUri(UriDecodeError),
352    Io(std::io::Error),
353    #[cfg(feature = "multimedia")]
354    Transcoding(image::ImageError),
355    #[cfg(feature = "multimedia")]
356    Tokio(tokio::task::JoinError),
357}
358
359impl From<std::io::Error> for PreviewError {
360    fn from(err: std::io::Error) -> Self {
361        Self::Io(err)
362    }
363}
364
365#[cfg(feature = "multimedia")]
366impl From<image::ImageError> for PreviewError {
367    fn from(err: image::ImageError) -> Self {
368        Self::Transcoding(err)
369    }
370}
371
372#[cfg(feature = "multimedia")]
373impl From<tokio::task::JoinError> for PreviewError {
374    fn from(err: tokio::task::JoinError) -> Self {
375        Self::Tokio(err)
376    }
377}
378
379impl std::fmt::Display for PreviewError {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        match self {
382            Self::TooLarge => {
383                write!(
384                    f,
385                    "preview size exceeds the max possible size({MAX_PREVIEW_BYTES} bytes)"
386                )
387            }
388            Self::BadUri(e) => write!(f, "{e}"),
389            Self::Io(error) => write!(f, "Cannot process preview file: {error}"),
390            #[cfg(feature = "multimedia")]
391            Self::Transcoding(error) => write!(f, "Cannot transcode preview: {error}"),
392            #[cfg(feature = "multimedia")]
393            Self::Tokio(error) => write!(f, "Failed to join the transcoding task: {error}"),
394        }
395    }
396}
397
398impl std::error::Error for PreviewError {
399    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
400        match self {
401            Self::TooLarge => None,
402            Self::BadUri(error) => Some(error),
403            Self::Io(error) => Some(error),
404            #[cfg(feature = "multimedia")]
405            Self::Transcoding(error) => Some(error),
406            #[cfg(feature = "multimedia")]
407            Self::Tokio(error) => Some(error),
408        }
409    }
410}
411
412#[derive(Debug)]
413pub enum UriDecodeError {
414    NotAUri,
415    Base64(base64::DecodeError),
416}
417
418impl std::fmt::Display for UriDecodeError {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            Self::NotAUri => write!(f, "not a URI string"),
422            Self::Base64(e) => write!(f, "{e}"),
423        }
424    }
425}
426
427impl std::error::Error for UriDecodeError {
428    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
429        if let Self::Base64(e) = self {
430            Some(e)
431        } else {
432            None
433        }
434    }
435}
436
437#[derive(Clone)]
438enum PreviewSource {
439    Default,
440    Bytes(Vec<u8>),
441    DataUri(String),
442    File(PathBuf),
443    #[cfg(feature = "native_crypto")]
444    CryptoFile(CryptoFile),
445}
446
447async fn read_plain_file(path: &PathBuf, size_limit: usize) -> std::io::Result<Vec<u8>> {
448    let mut f = tokio::fs::File::open(&path).await?;
449    let size_hint = f.seek(SeekFrom::End(0)).await?;
450    f.seek(SeekFrom::Start(0)).await?;
451    let size_hint: usize = util::cast_file_size(size_hint)?;
452
453    if size_hint > size_limit {
454        return Err(util::file_is_too_large(format!(
455            "Size exceeds {size_limit} bytes"
456        )));
457    }
458
459    let mut buf = Vec::with_capacity(size_hint);
460    f.read_to_end(&mut buf).await?;
461
462    Ok(buf)
463}
464
465#[cfg(feature = "native_crypto")]
466async fn read_crypto_file(file: CryptoFile, size_limit: usize) -> std::io::Result<Vec<u8>> {
467    let mut f = crate::crypto::fs::TokioMaybeCryptoFile::from_crypto_file(file).await?;
468    let size_hint = f.size_hint().await?;
469
470    if size_hint > size_limit {
471        return Err(util::file_is_too_large(format!(
472            "Size exceeds {size_limit} bytes"
473        )));
474    }
475
476    let mut buf = Vec::with_capacity(size_hint);
477    f.read_to_end(&mut buf).await?;
478
479    Ok(buf)
480}
481
482fn validate_uri_preview(uri: String) -> Result<String, PreviewError> {
483    let Some(s) = uri.strip_prefix(URI_HEADER) else {
484        return Err(PreviewError::BadUri(UriDecodeError::NotAUri));
485    };
486
487    if s.len() > MAX_PREVIEW_BYTES * 4 / 3 {
488        return Err(PreviewError::TooLarge);
489    }
490
491    Ok(uri)
492}