mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-23 01:46:41 -05:00
[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:
committed by
GitHub
parent
0d5264a7c9
commit
b0170b9dba
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
14
crates/svg/Cargo.toml
Normal 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
76
crates/svg/src/lib.rs
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user