[ENG-904] Thumbnail for svg (#1220)

* Initial implementation for svg rendering

* Remove unused errors
 - Round up size before allocating Pixmap
 - Re-order some operations

* Finish integrating with Thumbnailer
 - Fix svg thumbnail size
 - Fix incorrect color space while converting tiny_skia::Pixmap to image

* Fix Clippy warns

* Feedback + sd-heif async

* Update implementation to match recent changes to the Thumbnailer
 - Change sd-heif and sd-svg to receive the file data, instead of opening the file internally
 - Update changes to make Thumbnailer and sd-heif more async

* Minor import improvement

* Add missing cfg to gate use of sd_heif only to plataforms that have it enabled
This commit is contained in:
Vítor Vasconcellos
2023-08-31 20:01:45 -03:00
committed by GitHub
parent 0d5264a7c9
commit b0170b9dba
7 changed files with 219 additions and 86 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -27,6 +27,7 @@ sd-crypto = { path = "../crates/crypto", features = [
"serde",
"keymanager",
] }
sd-svg = { path = "../crates/svg" }
sd-heif = { path = "../crates/heif", optional = true }
sd-file-ext = { path = "../crates/file-ext" }
sd-sync = { path = "../crates/sync" }

View File

@@ -9,24 +9,25 @@ use crate::{
};
use sd_file_ext::extensions::{Extension, ImageExtension, ALL_IMAGE_EXTENSIONS};
use sd_media_metadata::image::Orientation;
use sd_media_metadata::image::{ExifReader, Orientation};
#[cfg(feature = "ffmpeg")]
use sd_file_ext::extensions::{VideoExtension, ALL_VIDEO_EXTENSIONS};
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
error::Error,
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
};
use futures_concurrency::future::{Join, TryJoin};
use image::{self, imageops, DynamicImage, GenericImageView};
use image::{self, imageops, DynamicImage, GenericImageView, ImageFormat};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{fs, io, task::block_in_place};
use tokio::{fs, io, task::spawn_blocking};
use tracing::{error, trace, warn};
use webp::Encoder;
@@ -86,6 +87,10 @@ pub enum ThumbnailerError {
FileIO(#[from] FileIOError),
#[error(transparent)]
VersionManager(#[from] VersionManagerError),
#[error("failed to encode webp")]
Encoding,
#[error("the image provided is too large")]
TooLarge,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
@@ -101,39 +106,66 @@ pub struct ThumbnailerMetadata {
pub skipped: u32,
}
// TOOD(brxken128): validate avci and avcs
#[cfg(all(feature = "heif", not(target_os = "linux")))]
const HEIF_EXTENSIONS: [&str; 7] = ["heif", "heifs", "heic", "heics", "avif", "avci", "avcs"];
static HEIF_EXTENSIONS: Lazy<HashSet<String>> = Lazy::new(|| {
["heif", "heifs", "heic", "heics", "avif", "avci", "avcs"]
.into_iter()
.map(|s| s.to_string())
.collect()
});
// The maximum file size that an image can be in order to have a thumbnail generated.
const MAXIMUM_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB
pub async fn generate_image_thumbnail<P: AsRef<Path>>(
file_path: P,
output_path: P,
) -> Result<(), Box<dyn Error>> {
// Webp creation has blocking code
let webp = block_in_place(|| -> Result<Vec<u8>, Box<dyn Error>> {
#[cfg(all(feature = "heif", not(target_os = "linux")))]
let img = {
let ext = file_path
.as_ref()
.extension()
.unwrap_or_default()
.to_ascii_lowercase();
if HEIF_EXTENSIONS
.iter()
.any(|e| ext == std::ffi::OsStr::new(e))
{
sd_heif::heif_to_dynamic_image(file_path.as_ref())?
} else {
image::open(file_path.as_ref())?
}
};
let file_path = file_path.as_ref();
#[cfg(not(all(feature = "heif", not(target_os = "linux"))))]
let img = image::open(file_path.as_ref())?;
let ext = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let ext = ext.as_str();
let orientation = Orientation::source_orientation(&file_path);
let metadata = fs::metadata(file_path)
.await
.map_err(|e| FileIOError::from((file_path, e)))?;
if metadata.len()
> (match ext {
"svg" => sd_svg::MAXIMUM_FILE_SIZE,
#[cfg(all(feature = "heif", not(target_os = "linux")))]
_ if HEIF_EXTENSIONS.contains(ext) => sd_heif::MAXIMUM_FILE_SIZE,
_ => MAXIMUM_FILE_SIZE,
}) {
return Err(ThumbnailerError::TooLarge.into());
}
let data = Arc::new(
fs::read(file_path)
.await
.map_err(|e| FileIOError::from((file_path, e)))?,
);
let img = match ext {
"svg" => sd_svg::svg_to_dynamic_image(data.clone()).await?,
_ if HEIF_EXTENSIONS.contains(ext) => {
#[cfg(not(all(feature = "heif", not(target_os = "linux"))))]
return Err("HEIF not supported".into());
#[cfg(all(feature = "heif", not(target_os = "linux")))]
sd_heif::heif_to_dynamic_image(data.clone()).await?
}
_ => image::load_from_memory_with_format(
&fs::read(file_path).await?,
ImageFormat::from_path(file_path)?,
)?,
};
let webp = spawn_blocking(move || -> Result<_, ThumbnailerError> {
let (w, h) = img.dimensions();
// Optionally, resize the existing photo and convert back into DynamicImage
let mut img = DynamicImage::ImageRgba8(imageops::resize(
&img,
@@ -143,13 +175,20 @@ pub async fn generate_image_thumbnail<P: AsRef<Path>>(
imageops::FilterType::Triangle,
));
// this corrects the rotation/flip of the image based on the available exif data
if let Some(x) = orientation {
img = x.correct_thumbnail(img);
match ExifReader::from_slice(data.as_ref()) {
Ok(exif_reader) => {
// this corrects the rotation/flip of the image based on the available exif data
if let Some(orientation) = Orientation::from_reader(&exif_reader) {
img = orientation.correct_thumbnail(img);
}
}
Err(e) => warn!("Unable to extract EXIF: {:?}", e),
}
// Create the WebP encoder for the above image
let encoder = Encoder::from_image(&img)?;
let Ok(encoder) = Encoder::from_image(&img) else {
return Err(ThumbnailerError::Encoding);
};
// Encode the image at a specified quality 0-100
@@ -157,7 +196,8 @@ pub async fn generate_image_thumbnail<P: AsRef<Path>>(
// this make us `deref` to have a `&[u8]` and then `to_owned` to make a Vec<u8>
// which implies on a unwanted clone...
Ok(encoder.encode(THUMBNAIL_QUALITY).deref().to_owned())
})?;
})
.await??;
let output_path = output_path.as_ref();
@@ -204,11 +244,11 @@ pub const fn can_generate_thumbnail_for_image(image_extension: &ImageExtension)
#[cfg(all(feature = "heif", not(target_os = "linux")))]
let res = matches!(
image_extension,
Jpg | Jpeg | Png | Webp | Gif | Heic | Heics | Heif | Heifs | Avif
Jpg | Jpeg | Png | Webp | Gif | Svg | Heic | Heics | Heif | Heifs | Avif
);
#[cfg(not(all(feature = "heif", not(target_os = "linux"))))]
let res = matches!(image_extension, Jpg | Jpeg | Png | Webp | Gif);
let res = matches!(image_extension, Jpg | Jpeg | Png | Webp | Gif | Svg);
res
}

View File

@@ -10,4 +10,6 @@ edition = { workspace = true }
libheif-rs = "0.19.2"
libheif-sys = "=1.14.2"
image = "0.24.6"
once_cell = "1.17.2"
tokio = { workspace = true, features = ["fs", "io-util"] }
thiserror = "1.0.40"

View File

@@ -1,19 +1,21 @@
use std::{
fs,
io::{Cursor, Read, Seek, SeekFrom},
path::Path,
io::{Cursor, SeekFrom},
sync::Arc,
};
use image::DynamicImage;
use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
use once_cell::sync::Lazy;
use thiserror::Error;
use tokio::{
io::{AsyncReadExt, AsyncSeekExt, BufReader},
task::{spawn_blocking, JoinError},
};
type HeifResult<T> = Result<T, HeifError>;
/// The maximum file size that an image can be in order to have a thumbnail generated.
///
/// This value is in MiB.
const HEIF_MAXIMUM_FILE_SIZE: u64 = 1048576 * 20;
// The maximum file size that an image can be in order to have a thumbnail generated.
pub const MAXIMUM_FILE_SIZE: u64 = 20 * 1024 * 1024; // 20MB
#[derive(Error, Debug)]
pub enum HeifError {
@@ -21,70 +23,68 @@ pub enum HeifError {
LibHeif(#[from] libheif_rs::HeifError),
#[error("error while loading the image (via the `image` crate): {0}")]
Image(#[from] image::ImageError),
#[error("io error: {0} at {}", .1.display())]
Io(std::io::Error, Box<Path>),
#[error("Blocking task failed to execute to completion.")]
Join(#[from] JoinError),
#[error("there was an error while converting the image to an `RgbImage`")]
RgbImageConversion,
#[error("the image provided is unsupported")]
Unsupported,
#[error("the image provided is too large (over 20MiB)")]
TooLarge,
#[error("the provided bit depth is invalid")]
InvalidBitDepth,
#[error("invalid path provided (non UTF-8)")]
InvalidPath,
}
pub fn heif_to_dynamic_image(path: &Path) -> HeifResult<DynamicImage> {
if fs::metadata(path)
.map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))?
.len() > HEIF_MAXIMUM_FILE_SIZE
{
return Err(HeifError::TooLarge);
}
static HEIF: Lazy<LibHeif> = Lazy::new(LibHeif::new);
let img = {
// do this in a separate block so we drop the raw (potentially huge) image handle
let ctx = HeifContext::read_from_file(path.to_str().ok_or(HeifError::InvalidPath)?)?;
let heif = LibHeif::new();
pub async fn heif_to_dynamic_image(data: Arc<Vec<u8>>) -> HeifResult<DynamicImage> {
let (img_data, stride, height, width) = spawn_blocking(move || -> Result<_, HeifError> {
let ctx = HeifContext::read_from_bytes(&data)?;
let handle = ctx.primary_image_handle()?;
let img = HEIF.decode(&handle, ColorSpace::Rgb(RgbChroma::Rgb), None)?;
heif.decode(&handle, ColorSpace::Rgb(RgbChroma::Rgb), None)?
};
// TODO(brxken128): add support for images with individual r/g/b channels
// i'm unable to find a sample to test with, but it should follow the same principles as this one
let Some(planes) = img.planes().interleaved else {
return Err(HeifError::Unsupported);
};
// TODO(brxken128): add support for images with individual r/g/b channels
// i'm unable to find a sample to test with, but it should follow the same principles as this one
if let Some(i) = img.planes().interleaved {
if i.bits_per_pixel != 8 {
if planes.bits_per_pixel != 8 {
return Err(HeifError::InvalidBitDepth);
}
let data = i.data.to_vec();
let mut reader = Cursor::new(data);
Ok((
planes.data.to_vec(),
planes.stride,
img.height(),
img.width(),
))
})
.await??;
let mut sequence = vec![];
let mut buffer = [0u8; 3]; // [r, g, b]
let mut buffer = [0u8; 3]; // [r, g, b]
let mut reader = BufReader::new(Cursor::new(img_data));
let mut sequence = vec![];
// this is the interpolation stuff, it essentially just makes the image correct
// in regards to stretching/resolution, etc
for y in 0..img.height() {
// this is the interpolation stuff, it essentially just makes the image correct
// in regards to stretching/resolution, etc
for y in 0..height {
reader
.seek(SeekFrom::Start((stride * y as usize) as u64))
.await
.map_err(|_| HeifError::RgbImageConversion)?;
for _ in 0..width {
reader
.seek(SeekFrom::Start((i.stride * y as usize) as u64))
.map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))?;
for _ in 0..img.width() {
reader
.read_exact(&mut buffer)
.map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))?;
sequence.extend_from_slice(&buffer);
}
.read_exact(&mut buffer)
.await
.map_err(|_| HeifError::RgbImageConversion)?;
sequence.extend_from_slice(&buffer);
}
let rgb_img = image::RgbImage::from_raw(img.width(), img.height(), sequence)
.ok_or(HeifError::RgbImageConversion)?;
Ok(DynamicImage::ImageRgb8(rgb_img))
} else {
Err(HeifError::Unsupported)
}
let rgb_img =
image::RgbImage::from_raw(width, height, sequence).ok_or(HeifError::RgbImageConversion)?;
Ok(DynamicImage::ImageRgb8(rgb_img))
}

14
crates/svg/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "sd-svg"
version = "0.1.0"
authors = ["Vítor Vasconcellos <vitor@spacedrive.com>"]
license = { workspace = true }
repository = { workspace = true }
edition = { workspace = true }
[dependencies]
image = "0.24.6"
resvg = "0.35.0"
thiserror = "1.0.40"
tokio = { workspace = true, features = ["fs", "io-util"] }
tracing = "0.1.37"

76
crates/svg/src/lib.rs Normal file
View File

@@ -0,0 +1,76 @@
use std::sync::Arc;
use image::DynamicImage;
use resvg::{
tiny_skia::{self, Pixmap},
usvg,
};
use thiserror::Error;
use tokio::task::{spawn_blocking, JoinError};
use tracing::error;
use usvg::{fontdb, TreeParsing, TreeTextToPath};
type SvgResult<T> = Result<T, SvgError>;
const THUMB_SIZE: u32 = 512;
// The maximum file size that an image can be in order to have a thumbnail generated.
pub const MAXIMUM_FILE_SIZE: u64 = 20 * 1024 * 1024; // 20MB
#[derive(Error, Debug)]
pub enum SvgError {
#[error("error with usvg: {0}")]
USvg(#[from] resvg::usvg::Error),
#[error("error while loading the image (via the `image` crate): {0}")]
Image(#[from] image::ImageError),
#[error("Blocking task failed to execute to completion")]
Join(#[from] JoinError),
#[error("failed to allocate `Pixbuf`")]
Pixbuf,
#[error("there was an error while converting the image to an `RgbImage`")]
RgbImageConversion,
#[error("failed to calculate thumbnail size")]
InvalidSize,
}
pub async fn svg_to_dynamic_image(data: Arc<Vec<u8>>) -> SvgResult<DynamicImage> {
let mut pixmap = spawn_blocking(move || -> Result<Pixmap, SvgError> {
let rtree = usvg::Tree::from_data(&data, &usvg::Options::default()).map(|mut tree| {
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
tree.convert_text(&fontdb);
resvg::Tree::from_usvg(&tree)
})?;
let size = if rtree.size.width() > rtree.size.height() {
rtree.size.to_int_size().scale_to_width(THUMB_SIZE)
} else {
rtree.size.to_int_size().scale_to_height(THUMB_SIZE)
}
.ok_or(SvgError::InvalidSize)?;
let transform = tiny_skia::Transform::from_scale(
size.width() as f32 / rtree.size.width(),
size.height() as f32 / rtree.size.height(),
);
let Some(mut pixmap) = tiny_skia::Pixmap::new(size.width(), size.height()) else {
return Err(SvgError::Pixbuf);
};
rtree.render(transform, &mut pixmap.as_mut());
Ok(pixmap)
})
.await??;
let Some(rgb_img) =
image::RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data_mut().into())
else {
return Err(SvgError::RgbImageConversion);
};
Ok(DynamicImage::ImageRgba8(rgb_img))
}