mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
[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:
parent
bbd1d9a5b7
commit
1b1ea7a1a4
442
Cargo.lock
generated
442
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"] }
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
@ -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
20
crates/images/Cargo.toml
Normal 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"
|
||||
26
crates/images/src/consts.rs
Normal file
26
crates/images/src/consts.rs
Normal 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;
|
||||
37
crates/images/src/error.rs
Normal file
37
crates/images/src/error.rs
Normal 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),
|
||||
}
|
||||
47
crates/images/src/formatter.rs
Normal file
47
crates/images/src/formatter.rs
Normal 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
|
||||
}
|
||||
22
crates/images/src/generic.rs
Normal file
22
crates/images/src/generic.rs
Normal 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
122
crates/images/src/heif.rs
Normal 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
60
crates/images/src/lib.rs
Normal 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
63
crates/images/src/svg.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user