[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 cafa022f2f
commit 7edcbb15d6
16 changed files with 442 additions and 300 deletions

BIN
Cargo.lock generated
View File

Binary file not shown.

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))
}