[ENG-621][ENG-1074] Trait-based image conversion overhaul (#1307)

* sd-images crate which will support raw/dng, bmp, etc

* more work on the image formatter

* re-work `sd-images`, add svg support, r/g/b and r/g/b/a HEIF image support (will all be async again soon)

* remove `ImageFormatter`, add note about r/g/b/(a) heif impl

* implement the image formatter

* rename the conversion trait and minor cleanups

* isolate heif feature and major cleanup

* very untested raw support

* change fn name to `from_path` (a lot more idiomatic)

* clean up orientation fixing

* heif is no longer forbidden (linux has good heif)

also all extensions are correctly matched in lowercase

* fix builds, ext matching, feature gating

* attempt to fix svg handling?

* raw attempt, quite a few errors

* add comment

* new (untested) attempt

* remove `raw` stuff for now

* replace `sd-svg` with a `ToImage` `SvgHandler` impl

* add some simple math to appropriately scale thumbnails (and bmp/ico support)

* add comments regarding how the math works for image thumbs

* rename the trait to `ImageHandler`
This commit is contained in:
jake 2023-09-07 15:08:17 +01:00 committed by GitHub
parent bbd1d9a5b7
commit 1b1ea7a1a4
16 changed files with 681 additions and 503 deletions

442
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ mobile = []
# This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
ffmpeg = ["dep:sd-ffmpeg"]
location-watcher = ["dep:notify"]
heif = ["dep:sd-heif"]
heif = ["sd-images/heif"]
[dependencies]
sd-media-metadata = { path = "../crates/media-metadata" }
@ -27,8 +27,8 @@ sd-crypto = { path = "../crates/crypto", features = [
"serde",
"keymanager",
] }
sd-svg = { path = "../crates/svg" }
sd-heif = { path = "../crates/heif", optional = true }
sd-images = { path = "../crates/images" }
sd-file-ext = { path = "../crates/file-ext" }
sd-sync = { path = "../crates/sync" }
sd-p2p = { path = "../crates/p2p", features = ["specta", "serde"] }

View File

@ -9,25 +9,25 @@ use crate::{
};
use sd_file_ext::extensions::{Extension, ImageExtension, ALL_IMAGE_EXTENSIONS};
use sd_media_metadata::image::{ExifReader, Orientation};
use sd_images::format_image;
use sd_media_metadata::image::Orientation;
#[cfg(feature = "ffmpeg")]
use sd_file_ext::extensions::{VideoExtension, ALL_VIDEO_EXTENSIONS};
use std::{
collections::{HashMap, HashSet},
collections::HashMap,
error::Error,
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
};
use futures_concurrency::future::{Join, TryJoin};
use image::{self, imageops, DynamicImage, GenericImageView, ImageFormat};
use image::{self, imageops, DynamicImage, GenericImageView};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{fs, io, task::spawn_blocking};
use tokio::{fs, io};
use tracing::{error, trace, warn};
use webp::Encoder;
@ -37,8 +37,6 @@ mod shard;
pub use directory::init_thumbnail_dir;
pub use shard::get_shard_hex;
const THUMBNAIL_SIZE_FACTOR: f32 = 0.2;
const THUMBNAIL_QUALITY: f32 = 30.0;
pub const THUMBNAIL_CACHE_DIR_NAME: &str = "thumbnails";
/// This does not check if a thumbnail exists, it just returns the path that it would exist at
@ -89,8 +87,24 @@ pub enum ThumbnailerError {
VersionManager(#[from] VersionManagerError),
#[error("failed to encode webp")]
Encoding,
#[error("the image provided is too large")]
TooLarge,
#[error("error while converting the image: {0}")]
SdImages(#[from] sd_images::Error),
}
/// This is the target pixel count for all thumbnails to be resized to, and it is eventually downscaled
/// to [`TARGET_QUALITY`].
const TAGRET_PX: f32 = 262144_f32;
/// This is the target quality that we render thumbnails at, it is a float between 0-100
/// and is treated as a percentage (so 30% in this case, or it's the same as multiplying by `0.3`).
const TARGET_QUALITY: f32 = 30_f32;
/// This takes in a width and a height, and returns a scaled width and height
/// It is scaled proportionally to the [`TARGET_PX`], so smaller images will be upscaled,
/// and larger images will be downscaled. This approach also maintains the aspect ratio of the image.
fn calculate_factor(w: f32, h: f32) -> (u32, u32) {
let sf = (TAGRET_PX / (w * h)).sqrt();
((w * sf).round() as u32, (h * sf).round() as u32)
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
@ -106,91 +120,30 @@ pub struct ThumbnailerMetadata {
pub skipped: u32,
}
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>> {
let file_path = file_path.as_ref();
) -> Result<(), ThumbnailerError> {
let file_path = file_path.as_ref().to_path_buf();
let ext = file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let ext = ext.as_str();
let webp = tokio::task::block_in_place(move || -> Result<_, ThumbnailerError> {
let img = format_image(&file_path).map_err(|_| ThumbnailerError::Encoding)?;
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());
}
#[cfg(all(feature = "heif", not(target_os = "linux")))]
if metadata.len() > sd_heif::MAXIMUM_FILE_SIZE && HEIF_EXTENSIONS.contains(ext) {
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();
let (w_scale, h_scale) = calculate_factor(w as f32, h as f32);
// Optionally, resize the existing photo and convert back into DynamicImage
let mut img = DynamicImage::ImageRgba8(imageops::resize(
&img,
// FIXME : Think of a better heuristic to get the thumbnail size
(w as f32 * THUMBNAIL_SIZE_FACTOR) as u32,
(h as f32 * THUMBNAIL_SIZE_FACTOR) as u32,
w_scale,
h_scale,
imageops::FilterType::Triangle,
));
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(sd_media_metadata::Error::NoExifDataOnSlice) => {
// No can do if we don't have exif data
}
Err(e) => warn!("Unable to extract EXIF: {:?}", e),
// this corrects the rotation/flip of the image based on the *available* exif data
// not all images have exif data, so we don't error
if let Some(orientation) = Orientation::from_path(file_path) {
img = orientation.correct_thumbnail(img);
}
// Create the WebP encoder for the above image
@ -198,14 +151,11 @@ pub async fn generate_image_thumbnail<P: AsRef<Path>>(
return Err(ThumbnailerError::Encoding);
};
// Encode the image at a specified quality 0-100
// Type WebPMemory is !Send, which makes the Future in this function !Send,
// 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??;
Ok(encoder.encode(TARGET_QUALITY).deref().to_owned())
})?;
let output_path = output_path.as_ref();
@ -214,11 +164,7 @@ pub async fn generate_image_thumbnail<P: AsRef<Path>>(
.await
.map_err(|e| FileIOError::from((shard_dir, e)))?;
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot determine parent shard directory for thumbnail",
)
.into());
return Err(ThumbnailerError::Encoding);
}
fs::write(output_path, &webp)
@ -234,7 +180,7 @@ pub async fn generate_video_thumbnail<P: AsRef<Path> + Send>(
) -> Result<(), Box<dyn Error>> {
use sd_ffmpeg::to_thumbnail;
to_thumbnail(file_path, output_path, 256, THUMBNAIL_QUALITY).await?;
to_thumbnail(file_path, output_path, 256, TARGET_QUALITY).await?;
Ok(())
}
@ -249,16 +195,10 @@ pub const fn can_generate_thumbnail_for_video(video_extension: &VideoExtension)
pub const fn can_generate_thumbnail_for_image(image_extension: &ImageExtension) -> bool {
use ImageExtension::*;
#[cfg(all(feature = "heif", not(target_os = "linux")))]
let res = matches!(
matches!(
image_extension,
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 | Svg);
res
Jpg | Jpeg | Png | Webp | Gif | Svg | Heic | Heics | Heif | Heifs | Avif | Bmp | Ico
)
}
pub(super) async fn process(

View File

@ -1,15 +0,0 @@
[package]
name = "sd-heif"
version = "0.1.0"
authors = ["Jake Robinson <jake@spacedrive.com>"]
license = { workspace = true }
repository = { workspace = true }
edition = { workspace = true }
[dependencies]
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,90 +0,0 @@
use std::{
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.
pub const MAXIMUM_FILE_SIZE: u64 = 20 * 1024 * 1024; // 20MB
#[derive(Error, Debug)]
pub enum HeifError {
#[error("error with libheif: {0}")]
LibHeif(#[from] libheif_rs::HeifError),
#[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("there was an error while converting the image to an `RgbImage`")]
RgbImageConversion,
#[error("the image provided is unsupported")]
Unsupported,
#[error("the provided bit depth is invalid")]
InvalidBitDepth,
#[error("invalid path provided (non UTF-8)")]
InvalidPath,
}
static HEIF: Lazy<LibHeif> = Lazy::new(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)?;
// 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);
};
if planes.bits_per_pixel != 8 {
return Err(HeifError::InvalidBitDepth);
}
Ok((
planes.data.to_vec(),
planes.stride,
img.height(),
img.width(),
))
})
.await??;
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..height {
reader
.seek(SeekFrom::Start((stride * y as usize) as u64))
.await
.map_err(|_| HeifError::RgbImageConversion)?;
for _ in 0..width {
reader
.read_exact(&mut buffer)
.await
.map_err(|_| HeifError::RgbImageConversion)?;
sequence.extend_from_slice(&buffer);
}
}
let rgb_img =
image::RgbImage::from_raw(width, height, sequence).ok_or(HeifError::RgbImageConversion)?;
Ok(DynamicImage::ImageRgb8(rgb_img))
}

20
crates/images/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "sd-images"
version = "0.0.0"
authors = [
"Jake Robinson <jake@spacedrive.com>",
"Vítor Vasconcellos <vitor@spacedrive.com>",
]
license = { workspace = true }
repository = { workspace = true }
edition = { workspace = true }
[features]
heif = ["dep:libheif-rs", "dep:libheif-sys"]
[dependencies]
libheif-rs = { version = "0.19.2", optional = true }
libheif-sys = { version = "=1.14.2", optional = true }
image = "0.24.7"
thiserror = "1.0.45"
resvg = "0.35.0"

View File

@ -0,0 +1,26 @@
/// The size of 1MiB in bytes
const MIB: u64 = 1_048_576;
pub const HEIF_EXTENSIONS: [&str; 7] = ["heif", "heifs", "heic", "heics", "avif", "avci", "avcs"];
/// The maximum file size that an image can be in order to have a thumbnail generated.
///
/// This value is in MiB.
pub const HEIF_MAXIMUM_FILE_SIZE: u64 = MIB * 32;
pub const SVG_EXTENSIONS: [&str; 2] = ["svg", "svgz"];
/// The maximum file size that an image can be in order to have a thumbnail generated.
///
/// This value is in MiB.
pub const SVG_MAXIMUM_FILE_SIZE: u64 = MIB * 24;
/// The size that SVG images are rendered at, assuming they are square.
// TODO(brxken128): check for non-1:1 SVG images and create a function to resize
// them while maintaining the aspect ratio.
pub const SVG_RENDER_SIZE: u32 = 512;
/// The maximum file size that an image can be in order to have a thumbnail generated.
///
/// This value is in MiB.
pub const GENERIC_MAXIMUM_FILE_SIZE: u64 = MIB * 64;

View File

@ -0,0 +1,37 @@
use std::num::TryFromIntError;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[cfg(feature = "heif")]
#[error("error with libheif: {0}")]
LibHeif(#[from] libheif_rs::HeifError),
#[error("error with usvg: {0}")]
USvg(#[from] resvg::usvg::Error),
#[error("failed to allocate `Pixbuf` while converting an SVG")]
Pixbuf,
#[error("error while loading the image (via the `image` crate): {0}")]
Image(#[from] image::ImageError),
#[error("there was an i/o error: {0}")]
Io(#[from] std::io::Error),
#[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,
#[error("the image has an invalid length to be RGB")]
InvalidLength,
#[error("invalid path provided (it had no file extension)")]
NoExtension,
#[error("error while converting from raw")]
RawConversion,
#[error("error while parsing integers")]
TryFromInt(#[from] TryFromIntError),
}

View File

@ -0,0 +1,47 @@
use crate::{
consts,
error::{Error, Result},
generic::GenericHandler,
svg::SvgHandler,
ImageHandler,
};
use image::DynamicImage;
use std::{
ffi::{OsStr, OsString},
path::Path,
};
#[cfg(feature = "heif")]
use crate::heif::HeifHandler;
pub fn format_image(path: impl AsRef<Path>) -> Result<DynamicImage> {
let ext = path
.as_ref()
.extension()
.map_or_else(|| Err(Error::NoExtension), |e| Ok(e.to_ascii_lowercase()))?;
match_to_handler(&ext).handle_image(path.as_ref())
}
#[allow(clippy::useless_let_if_seq)]
fn match_to_handler(ext: &OsStr) -> Box<dyn ImageHandler> {
let mut handler: Box<dyn ImageHandler> = Box::new(GenericHandler {});
#[cfg(feature = "heif")]
if consts::HEIF_EXTENSIONS
.iter()
.map(OsString::from)
.any(|x| x == ext)
{
handler = Box::new(HeifHandler {});
}
if consts::SVG_EXTENSIONS
.iter()
.map(OsString::from)
.any(|x| x == ext)
{
handler = Box::new(SvgHandler {});
}
handler
}

View File

@ -0,0 +1,22 @@
use crate::consts::GENERIC_MAXIMUM_FILE_SIZE;
pub use crate::error::{Error, Result};
use crate::ImageHandler;
use image::DynamicImage;
use std::path::Path;
pub struct GenericHandler {}
impl ImageHandler for GenericHandler {
fn maximum_size(&self) -> u64 {
GENERIC_MAXIMUM_FILE_SIZE
}
fn validate_image(&self, _bits_per_pixel: u8, _length: usize) -> Result<()> {
Ok(())
}
fn handle_image(&self, path: &Path) -> Result<DynamicImage> {
let data = self.get_data(path)?; // this also makes sure the file isn't above the maximum size
Ok(image::load_from_memory(&data)?)
}
}

122
crates/images/src/heif.rs Normal file
View File

@ -0,0 +1,122 @@
pub use crate::consts::HEIF_EXTENSIONS;
use crate::consts::HEIF_MAXIMUM_FILE_SIZE;
pub use crate::error::{Error, Result};
use crate::ImageHandler;
use image::DynamicImage;
use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
use std::io::{Cursor, SeekFrom};
use std::io::{Read, Seek};
use std::path::Path;
pub struct HeifHandler {}
impl ImageHandler for HeifHandler {
fn maximum_size(&self) -> u64 {
HEIF_MAXIMUM_FILE_SIZE
}
fn validate_image(&self, bits_per_pixel: u8, length: usize) -> Result<()> {
if bits_per_pixel != 8 {
return Err(Error::InvalidBitDepth);
} else if length % 3 != 0 || length % 4 != 0 {
return Err(Error::InvalidLength);
}
Ok(())
}
fn handle_image(&self, path: &Path) -> Result<DynamicImage> {
let img = {
let data = self.get_data(path)?;
let handle = HeifContext::read_from_bytes(&data)?.primary_image_handle()?;
LibHeif::new().decode(&handle, ColorSpace::Rgb(RgbChroma::Rgb), None)
}?;
let planes = img.planes();
if let Some(i) = planes.interleaved {
self.validate_image(i.bits_per_pixel, i.data.len())?;
let mut reader = Cursor::new(i.data);
let mut sequence = vec![];
let mut buffer = [0u8; 3]; // [r, g, b]
// this is the interpolation stuff, it essentially just makes the image correct
// in regards to stretching/resolution, etc
(0..img.height()).try_for_each(|x| {
let x: usize = x.try_into()?;
let start: u64 = (i.stride * x).try_into()?;
reader.seek(SeekFrom::Start(start))?;
(0..img.width()).try_for_each(|_| {
reader.read_exact(&mut buffer)?;
sequence.extend_from_slice(&buffer);
Ok::<(), Error>(())
})?;
Ok::<(), Error>(())
})?;
image::RgbImage::from_raw(img.width(), img.height(), sequence).map_or_else(
|| Err(Error::RgbImageConversion),
|x| Ok(DynamicImage::ImageRgb8(x)),
)
} else if let (Some(r), Some(g), Some(b)) = (planes.r, planes.g, planes.b) {
// This implementation is **ENTIRELY** untested, as I'm unable to source
// a HEIF image that has separate r/g/b channels, let alone r/g/b/a.
// This was hand-crafted using my best judgement, and I think it should work.
// I'm sure we'll get a GH issue opened regarding it if not - brxken128
self.validate_image(r.bits_per_pixel, r.data.len())?;
self.validate_image(g.bits_per_pixel, g.data.len())?;
self.validate_image(b.bits_per_pixel, b.data.len())?;
let mut red = Cursor::new(r.data);
let mut green = Cursor::new(g.data);
let mut blue = Cursor::new(b.data);
let (mut alpha, has_alpha) = if let Some(a) = planes.a {
self.validate_image(a.bits_per_pixel, a.data.len())?;
(Cursor::new(a.data), true)
} else {
(Cursor::new([].as_ref()), false)
};
let mut sequence = vec![];
let mut buffer: [u8; 4] = [0u8; 4];
// this is the interpolation stuff, it essentially just makes the image correct
// in regards to stretching/resolution, etc
(0..img.height()).try_for_each(|x| {
let x: usize = x.try_into()?;
let start: u64 = (r.stride * x).try_into()?;
red.seek(SeekFrom::Start(start))?;
(0..img.width()).try_for_each(|_| {
red.read_exact(&mut buffer[0..1])?;
green.read_exact(&mut buffer[1..2])?;
blue.read_exact(&mut buffer[2..3])?;
sequence.extend_from_slice(&buffer[..3]);
if has_alpha {
alpha.read_exact(&mut buffer[3..4])?;
sequence.extend_from_slice(&buffer[3..4]);
}
Ok::<(), Error>(())
})?;
Ok::<(), Error>(())
})?;
if has_alpha {
image::RgbaImage::from_raw(img.width(), img.height(), sequence).map_or_else(
|| Err(Error::RgbImageConversion),
|x| Ok(DynamicImage::ImageRgba8(x)),
)
} else {
image::RgbImage::from_raw(img.width(), img.height(), sequence).map_or_else(
|| Err(Error::RgbImageConversion),
|x| Ok(DynamicImage::ImageRgb8(x)),
)
}
} else {
Err(Error::Unsupported)
}
}
}

60
crates/images/src/lib.rs Normal file
View File

@ -0,0 +1,60 @@
#![warn(
clippy::all,
clippy::pedantic,
clippy::correctness,
clippy::perf,
clippy::style,
clippy::suspicious,
clippy::complexity,
clippy::nursery,
clippy::unwrap_used,
unused_qualifications,
rust_2018_idioms,
clippy::expect_used,
trivial_casts,
trivial_numeric_casts,
unused_allocation,
clippy::as_conversions,
clippy::dbg_macro
)]
#![forbid(unsafe_code)]
#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)]
mod consts;
mod error;
mod formatter;
mod generic;
#[cfg(feature = "heif")]
mod heif;
mod svg;
pub use error::{Error, Result};
pub use formatter::format_image;
pub use image::DynamicImage;
use std::{fs, io::Read, path::Path};
pub trait ImageHandler {
fn maximum_size(&self) -> u64
where
Self: Sized; // thanks vtables
fn get_data(&self, path: &Path) -> Result<Vec<u8>>
where
Self: Sized,
{
let mut file = fs::File::open(path)?;
if file.metadata()?.len() > self.maximum_size() {
Err(Error::TooLarge)
} else {
let mut data = vec![];
file.read_to_end(&mut data)?;
Ok(data)
}
}
fn validate_image(&self, bits_per_pixel: u8, length: usize) -> Result<()>
where
Self: Sized;
fn handle_image(&self, path: &Path) -> Result<DynamicImage>;
}

63
crates/images/src/svg.rs Normal file
View File

@ -0,0 +1,63 @@
use std::path::Path;
use crate::{
consts::{SVG_MAXIMUM_FILE_SIZE, SVG_RENDER_SIZE},
Error, ImageHandler, Result,
};
use image::DynamicImage;
use resvg::{
tiny_skia::{self},
usvg,
};
use usvg::{fontdb, TreeParsing, TreeTextToPath};
pub struct SvgHandler {}
impl ImageHandler for SvgHandler {
fn maximum_size(&self) -> u64 {
SVG_MAXIMUM_FILE_SIZE
}
fn validate_image(&self, _bits_per_pixel: u8, _length: usize) -> Result<()> {
Ok(())
}
fn handle_image(&self, path: &Path) -> Result<DynamicImage> {
let data = self.get_data(path)?;
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(SVG_RENDER_SIZE) // make this a const
} else {
rtree.size.to_int_size().scale_to_height(SVG_RENDER_SIZE)
}
.ok_or(Error::InvalidLength)?;
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::as_conversions)]
let transform = tiny_skia::Transform::from_scale(
size.width() as f32 / rtree.size.width(),
size.height() as f32 / rtree.size.height(),
);
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::as_conversions)]
let Some(mut pixmap) = tiny_skia::Pixmap::new(size.width(), size.height()) else {
return Err(Error::Pixbuf);
};
rtree.render(transform, &mut pixmap.as_mut());
image::RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().into())
.map_or_else(
|| Err(Error::RgbImageConversion),
|x| Ok(DynamicImage::ImageRgba8(x)),
)
}
}

View File

@ -21,7 +21,7 @@ pub enum Orientation {
impl Orientation {
/// This is used for quickly sourcing [`Orientation`] data from a path, to be later used by one of the modification functions.
#[allow(clippy::future_not_send)]
pub fn source_orientation(path: impl AsRef<Path>) -> Option<Self> {
pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
let reader = ExifReader::from_path(path).ok()?;
reader.get_tag_int(Tag::Orientation).map(Into::into)
}

View File

@ -1,14 +0,0 @@
[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"

View File

@ -1,76 +0,0 @@
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))
}