From ad1837f9dd2adc9cef004206cdc53dfe14db8173 Mon Sep 17 00:00:00 2001 From: asonix Date: Wed, 12 Jul 2023 22:12:21 -0500 Subject: [PATCH] VERY BROKEN: start replacing parts of pict-rs --- src/details.rs | 33 +- src/discover.rs | 40 ++ src/discover/exiftool.rs | 54 ++ src/discover/ffmpeg.rs | 297 ++++++++++ src/discover/magick.rs | 307 +++++++++++ src/ffmpeg.rs | 5 +- src/ffmpeg/ffprobe_6_0_apng_details.json | 15 + src/ffmpeg/tests.rs | 17 +- src/formats.rs | 670 +++++------------------ src/formats/animation.rs | 84 +++ src/formats/image.rs | 92 ++++ src/formats/mimes.rs | 23 + src/formats/video.rs | 372 +++++++++++++ src/generate.rs | 23 +- src/ingest.rs | 22 +- src/lib.rs | 20 +- src/magick.rs | 23 +- src/queue.rs | 9 +- src/queue/process.rs | 12 +- src/validate.rs | 115 ++-- src/validate/exiftool.rs | 12 + src/validate/ffmpeg.rs | 112 ++++ src/validate/magick.rs | 39 ++ 23 files changed, 1748 insertions(+), 648 deletions(-) create mode 100644 src/discover.rs create mode 100644 src/discover/exiftool.rs create mode 100644 src/discover/ffmpeg.rs create mode 100644 src/discover/magick.rs create mode 100644 src/ffmpeg/ffprobe_6_0_apng_details.json create mode 100644 src/formats/animation.rs create mode 100644 src/formats/image.rs create mode 100644 src/formats/mimes.rs create mode 100644 src/formats/video.rs create mode 100644 src/validate/exiftool.rs create mode 100644 src/validate/ffmpeg.rs create mode 100644 src/validate/magick.rs diff --git a/src/details.rs b/src/details.rs index fea1251..1e760d6 100644 --- a/src/details.rs +++ b/src/details.rs @@ -1,12 +1,12 @@ use crate::{ error::Error, - ffmpeg::VideoFormat, + formats::{InternalFormat, InternalVideoFormat}, magick::{video_mp4, video_webm, ValidInputType}, serde_str::Serde, store::Store, }; use actix_web::web; -use time::format_description::well_known::Rfc3339; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; #[derive(Copy, Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(untagged)] @@ -22,6 +22,8 @@ pub(crate) struct Details { frames: Option, content_type: Serde, created_at: MaybeHumanDate, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, } impl Details { @@ -88,6 +90,7 @@ impl Details { frames, content_type: Serde::new(content_type), created_at: MaybeHumanDate::HumanDate(time::OffsetDateTime::now_utc()), + format: None, } } @@ -99,21 +102,33 @@ impl Details { self.created_at.into() } - pub(crate) fn to_input_format(&self) -> Option { - if *self.content_type == mime::IMAGE_GIF { - return Some(VideoFormat::Gif); - } - + pub(crate) fn input_format(&self) -> Option { if *self.content_type == video_mp4() { - return Some(VideoFormat::Mp4); + return Some(InternalVideoFormat::Mp4); } if *self.content_type == video_webm() { - return Some(VideoFormat::Webm); + return Some(InternalVideoFormat::Webm); } None } + + pub(crate) fn from_parts( + format: InternalFormat, + width: u16, + height: u16, + frames: Option, + ) -> Self { + Self { + width: width.into(), + height: height.into(), + frames: frames.map(|f| f.try_into().expect("Reasonable size")), + content_type: Serde::new(format.media_type()), + created_at: MaybeHumanDate::HumanDate(OffsetDateTime::now_utc()), + format: Some(format), + } + } } impl From for std::time::SystemTime { diff --git a/src/discover.rs b/src/discover.rs new file mode 100644 index 0000000..aac21f1 --- /dev/null +++ b/src/discover.rs @@ -0,0 +1,40 @@ +mod exiftool; +mod ffmpeg; +mod magick; + +use actix_web::web::Bytes; + +use crate::formats::{AnimationInput, ImageInput, InputFile, InternalFormat, InternalVideoFormat}; + +pub(crate) struct Discovery { + pub(crate) input: InputFile, + pub(crate) width: u16, + pub(crate) height: u16, + pub(crate) frames: Option, +} + +impl Discovery { + pub(crate) fn internal_format(&self) -> InternalFormat { + match self.input { + InputFile::Image(ImageInput { format, .. }) => InternalFormat::Image(format), + InputFile::Animation(AnimationInput { format }) => InternalFormat::Animation(format), + // we're making up bs now lol + InputFile::Video(crate::formats::VideoFormat::Mp4) => { + InternalFormat::Video(InternalVideoFormat::Mp4) + } + InputFile::Video(crate::formats::VideoFormat::Webm { .. }) => { + InternalFormat::Video(InternalVideoFormat::Webm) + } + } + } +} + +pub(crate) async fn discover_bytes(bytes: Bytes) -> Result { + let discovery = ffmpeg::discover_bytes(bytes.clone()).await?; + + let discovery = magick::confirm_bytes(discovery, bytes.clone()).await?; + + let discovery = exiftool::check_reorient(discovery, bytes).await?; + + Ok(discovery) +} diff --git a/src/discover/exiftool.rs b/src/discover/exiftool.rs new file mode 100644 index 0000000..a194827 --- /dev/null +++ b/src/discover/exiftool.rs @@ -0,0 +1,54 @@ +use actix_web::web::Bytes; +use tokio::io::AsyncReadExt; + +use crate::{ + exiftool::ExifError, + formats::{ImageInput, InputFile}, + process::Process, +}; + +use super::Discovery; + +pub(super) async fn check_reorient( + Discovery { + input, + width, + height, + frames, + }: Discovery, + bytes: Bytes, +) -> Result { + let input = match input { + InputFile::Image(ImageInput { format, .. }) => { + let needs_reorient = needs_reorienting(bytes).await?; + + InputFile::Image(ImageInput { + format, + needs_reorient, + }) + } + otherwise => otherwise, + }; + + Ok(Discovery { + input, + width, + height, + frames, + }) +} + +#[tracing::instrument(level = "trace", skip(input))] +async fn needs_reorienting(input: Bytes) -> Result { + let process = + Process::run("exiftool", &["-n", "-Orientation", "-"]).map_err(ExifError::Process)?; + let mut reader = process.bytes_read(input); + + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .await + .map_err(ExifError::Read)?; + + Ok(!buf.is_empty()) +} diff --git a/src/discover/ffmpeg.rs b/src/discover/ffmpeg.rs new file mode 100644 index 0000000..cb1799e --- /dev/null +++ b/src/discover/ffmpeg.rs @@ -0,0 +1,297 @@ +use std::{collections::HashSet, sync::OnceLock}; + +use crate::{ + ffmpeg::FfMpegError, + formats::{AnimationFormat, AnimationInput, ImageFormat, ImageInput, InputFile, VideoFormat}, + process::Process, +}; +use actix_web::web::Bytes; +use tokio::io::AsyncReadExt; + +use super::Discovery; + +const FFMPEG_FORMAT_MAPPINGS: &[(&str, InputFile)] = &[ + ( + "apng", + InputFile::Animation(AnimationInput { + format: AnimationFormat::Apng, + }), + ), + ( + "gif", + InputFile::Animation(AnimationInput { + format: AnimationFormat::Gif, + }), + ), + ("mp4", InputFile::Video(VideoFormat::Mp4)), + ( + "png_pipe", + InputFile::Image(ImageInput { + format: ImageFormat::Png, + needs_reorient: false, + }), + ), + ("webm", InputFile::Video(VideoFormat::Webm { alpha: false })), + ( + "webp_pipe", + InputFile::Image(ImageInput { + format: ImageFormat::Webp, + needs_reorient: false, + }), + ), +]; + +#[derive(Debug, serde::Deserialize)] +struct FfMpegDiscovery { + streams: [FfMpegStream; 1], + format: FfMpegFormat, +} + +#[derive(Debug, serde::Deserialize)] +struct FfMpegStream { + width: u16, + height: u16, + nb_read_frames: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct FfMpegFormat { + format_name: String, +} + +#[derive(serde::Deserialize)] +struct PixelFormatOutput { + pixel_formats: Vec, +} + +#[derive(serde::Deserialize)] +struct PixelFormat { + name: String, + flags: Flags, +} + +#[derive(serde::Deserialize)] +struct Flags { + alpha: usize, +} + +pub(super) async fn discover_bytes(bytes: Bytes) -> Result, FfMpegError> { + discover_file(move |mut file| async move { + file.write_from_bytes(bytes) + .await + .map_err(FfMpegError::Write)?; + Ok(file) + }) + .await +} + +#[tracing::instrument(skip(f))] +async fn discover_file(f: F) -> Result, FfMpegError> +where + F: FnOnce(crate::file::File) -> Fut, + Fut: std::future::Future>, +{ + let input_file = crate::tmp_file::tmp_file(None); + let input_file_str = input_file.to_str().ok_or(FfMpegError::Path)?; + crate::store::file_store::safe_create_parent(&input_file) + .await + .map_err(FfMpegError::CreateDir)?; + + let tmp_one = crate::file::File::create(&input_file) + .await + .map_err(FfMpegError::CreateFile)?; + let tmp_one = (f)(tmp_one).await?; + tmp_one.close().await.map_err(FfMpegError::CloseFile)?; + + let process = Process::run( + "ffprobe", + &[ + "-v", + "quiet", + "-select_streams", + "v:0", + "-count_frames", + "-show_entries", + "stream=width,height,nb_read_frames:format=format_name", + "-of", + "default=noprint_wrappers=1:nokey=1", + "-print_format", + "json", + input_file_str, + ], + ) + .map_err(FfMpegError::Process)?; + + let mut output = Vec::new(); + process + .read() + .read_to_end(&mut output) + .await + .map_err(FfMpegError::Read)?; + tokio::fs::remove_file(input_file_str) + .await + .map_err(FfMpegError::RemoveFile)?; + + let output: FfMpegDiscovery = serde_json::from_slice(&output).map_err(FfMpegError::Json)?; + + let Some(discovery) = parse_discovery_ffmpeg(output)? else { + return Ok(None); + }; + + match discovery { + Discovery { + input: InputFile::Video(VideoFormat::Webm { .. }), + width, + height, + frames, + } => { + static ALPHA_PIXEL_FORMATS: OnceLock> = OnceLock::new(); + + let format = pixel_format(input_file_str).await?; + + let alpha = match ALPHA_PIXEL_FORMATS.get() { + Some(alpha_pixel_formats) => alpha_pixel_formats.contains(&format), + None => { + let pixel_formats = alpha_pixel_formats().await?; + let alpha = pixel_formats.contains(&format); + let _ = ALPHA_PIXEL_FORMATS.set(pixel_formats); + alpha + } + }; + + Ok(Some(Discovery { + input: InputFile::Video(VideoFormat::Webm { alpha }), + width, + height, + frames, + })) + } + otherwise => Ok(Some(otherwise)), + } +} + +async fn pixel_format(input_file: &str) -> Result { + let process = Process::run( + "ffprobe", + &[ + "-v", + "0", + "-select_streams", + "v:0", + "-show_entries", + "stream=pix_fmt", + "-of", + "compact=p=0:nk=1", + input_file, + ], + ) + .map_err(FfMpegError::Process)?; + + let mut output = Vec::new(); + process + .read() + .read_to_end(&mut output) + .await + .map_err(FfMpegError::Read)?; + Ok(String::from_utf8_lossy(&output).trim().to_string()) +} + +async fn alpha_pixel_formats() -> Result, FfMpegError> { + let process = Process::run( + "ffprobe", + &[ + "-v", + "0", + "-show_entries", + "pixel_format=name:flags=alpha", + "-of", + "compact=p=0", + "-print_format", + "json", + ], + ) + .map_err(FfMpegError::Process)?; + + let mut output = Vec::new(); + process + .read() + .read_to_end(&mut output) + .await + .map_err(FfMpegError::Read)?; + + let formats: PixelFormatOutput = serde_json::from_slice(&output).map_err(FfMpegError::Json)?; + + Ok(parse_pixel_formats(formats)) +} + +fn parse_pixel_formats(formats: PixelFormatOutput) -> HashSet { + formats + .pixel_formats + .into_iter() + .filter_map(|PixelFormat { name, flags }| { + if flags.alpha == 0 { + return None; + } + + Some(name) + }) + .collect() +} + +fn parse_discovery_ffmpeg(discovery: FfMpegDiscovery) -> Result, FfMpegError> { + let FfMpegDiscovery { + streams: + [FfMpegStream { + width, + height, + nb_read_frames, + }], + format: FfMpegFormat { format_name }, + } = discovery; + + if let Some((name, value)) = FFMPEG_FORMAT_MAPPINGS + .iter() + .find(|(name, _)| format_name.contains(name)) + { + let frames = nb_read_frames.and_then(|frames| frames.parse().ok()); + + if *name == "mp4" && frames.map(|nb| nb == 1).unwrap_or(false) { + // Might be AVIF, ffmpeg incorrectly detects AVIF as single-framed mp4 even when + // animated + + return Ok(Some(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Avif, + }), + width, + height, + frames, + })); + } + + if *name == "webp" && (frames.is_none() || width == 0 || height == 0) { + // Might be Animated Webp, ffmpeg incorrectly detects animated webp as having no frames + // and 0 dimensions + + return Ok(Some(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Webp, + }), + width, + height, + frames, + })); + } + + return Ok(Some(Discovery { + input: value.clone(), + width, + height, + frames, + })); + } + + tracing::info!("No matching format mapping for {format_name}"); + + Ok(None) +} diff --git a/src/discover/magick.rs b/src/discover/magick.rs new file mode 100644 index 0000000..bb27591 --- /dev/null +++ b/src/discover/magick.rs @@ -0,0 +1,307 @@ +use actix_web::web::Bytes; +use tokio::io::AsyncReadExt; + +use crate::{ + formats::{AnimationFormat, AnimationInput, ImageFormat, ImageInput, InputFile, VideoFormat}, + magick::MagickError, + process::Process, +}; + +use super::Discovery; + +#[derive(Debug, serde::Deserialize)] +struct MagickDiscovery { + image: Image, +} + +#[derive(Debug, serde::Deserialize)] +struct Image { + format: String, + geometry: Geometry, +} + +#[derive(Debug, serde::Deserialize)] +struct Geometry { + width: u16, + height: u16, +} + +pub(super) async fn confirm_bytes( + discovery: Option, + bytes: Bytes, +) -> Result { + match discovery { + Some(Discovery { + input: + InputFile::Animation(AnimationInput { + format: AnimationFormat::Avif, + }), + width, + height, + .. + }) => { + let frames = count_avif_frames(move |mut file| async move { + file.write_from_bytes(bytes) + .await + .map_err(MagickError::Write)?; + Ok(file) + }) + .await?; + + return Ok(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Avif, + }), + width, + height, + frames: Some(frames), + }); + } + Some(Discovery { + input: + InputFile::Animation(AnimationInput { + format: AnimationFormat::Webp, + }), + .. + }) => { + // continue + } + Some(otherwise) => return Ok(otherwise), + None => { + // continue + } + } + + discover_file(move |mut file| async move { + file.write_from_bytes(bytes) + .await + .map_err(MagickError::Write)?; + + Ok(file) + }) + .await +} + +async fn count_avif_frames(f: F) -> Result +where + F: FnOnce(crate::file::File) -> Fut, + Fut: std::future::Future>, +{ + let input_file = crate::tmp_file::tmp_file(None); + let input_file_str = input_file.to_str().ok_or(MagickError::Path)?; + crate::store::file_store::safe_create_parent(&input_file) + .await + .map_err(MagickError::CreateDir)?; + + let tmp_one = crate::file::File::create(&input_file) + .await + .map_err(MagickError::CreateFile)?; + let tmp_one = (f)(tmp_one).await?; + tmp_one.close().await.map_err(MagickError::CloseFile)?; + + let process = Process::run("magick", &["convert", "-ping", input_file_str, "INFO:"]) + .map_err(MagickError::Process)?; + + let mut output = String::new(); + process + .read() + .read_to_string(&mut output) + .await + .map_err(MagickError::Read)?; + tokio::fs::remove_file(input_file_str) + .await + .map_err(MagickError::RemoveFile)?; + + let lines: u32 = output + .lines() + .count() + .try_into() + .expect("Reasonable frame count"); + + if lines == 0 { + todo!("Error"); + } + + Ok(lines) +} + +async fn discover_file(f: F) -> Result +where + F: FnOnce(crate::file::File) -> Fut, + Fut: std::future::Future>, +{ + let input_file = crate::tmp_file::tmp_file(None); + let input_file_str = input_file.to_str().ok_or(MagickError::Path)?; + crate::store::file_store::safe_create_parent(&input_file) + .await + .map_err(MagickError::CreateDir)?; + + let tmp_one = crate::file::File::create(&input_file) + .await + .map_err(MagickError::CreateFile)?; + let tmp_one = (f)(tmp_one).await?; + tmp_one.close().await.map_err(MagickError::CloseFile)?; + + let process = Process::run("magick", &["convert", "-ping", input_file_str, "JSON:"]) + .map_err(MagickError::Process)?; + + let mut output = Vec::new(); + process + .read() + .read_to_end(&mut output) + .await + .map_err(MagickError::Read)?; + tokio::fs::remove_file(input_file_str) + .await + .map_err(MagickError::RemoveFile)?; + + let output: Vec = + serde_json::from_slice(&output).map_err(MagickError::Json)?; + + parse_discovery(output) +} + +fn parse_discovery(output: Vec) -> Result { + let frames = output.len(); + + if frames == 0 { + todo!("Error") + } + + let width = output + .iter() + .map( + |MagickDiscovery { + image: + Image { + geometry: Geometry { width, .. }, + .. + }, + }| *width, + ) + .max() + .expect("Nonempty vector"); + + let height = output + .iter() + .map( + |MagickDiscovery { + image: + Image { + geometry: Geometry { height, .. }, + .. + }, + }| *height, + ) + .max() + .expect("Nonempty vector"); + + let first_format = &output[0].image.format; + + if output.iter().any( + |MagickDiscovery { + image: Image { format, .. }, + }| format != first_format, + ) { + todo!("Error") + } + + let frames: u32 = frames.try_into().expect("Reasonable frame count"); + + match first_format.as_str() { + "AVIF" => { + if frames > 1 { + Ok(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Avif, + }), + width, + height, + frames: Some(frames), + }) + } else { + Ok(Discovery { + input: InputFile::Image(ImageInput { + format: ImageFormat::Avif, + needs_reorient: false, + }), + width, + height, + frames: None, + }) + } + } + "APNG" => Ok(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Apng, + }), + width, + height, + frames: Some(frames), + }), + "GIF" => Ok(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Gif, + }), + width, + height, + frames: Some(frames), + }), + "JPEG" => Ok(Discovery { + input: InputFile::Image(ImageInput { + format: ImageFormat::Jpeg, + needs_reorient: false, + }), + width, + height, + frames: None, + }), + "JXL" => Ok(Discovery { + input: InputFile::Image(ImageInput { + format: ImageFormat::Jxl, + needs_reorient: false, + }), + width, + height, + frames: None, + }), + "MP4" => Ok(Discovery { + input: InputFile::Video(VideoFormat::Mp4), + width, + height, + frames: Some(frames), + }), + "PNG" => Ok(Discovery { + input: InputFile::Image(ImageInput { + format: ImageFormat::Png, + needs_reorient: false, + }), + width, + height, + frames: None, + }), + "WEBP" => { + if frames > 1 { + Ok(Discovery { + input: InputFile::Animation(AnimationInput { + format: AnimationFormat::Webp, + }), + width, + height, + frames: Some(frames), + }) + } else { + Ok(Discovery { + input: InputFile::Image(ImageInput { + format: ImageFormat::Webp, + needs_reorient: false, + }), + width, + height, + frames: None, + }) + } + } + otherwise => todo!("Error {otherwise}"), + } +} diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 71eca76..1403447 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -3,6 +3,7 @@ mod tests; use crate::{ config::{AudioCodec, ImageFormat, MediaConfiguration, VideoCodec}, + formats::InternalVideoFormat, magick::{Details, ParseDetailsError, ValidInputType, ValidateDetailsError}, process::{Process, ProcessError}, store::{Store, StoreError}, @@ -716,10 +717,10 @@ pub(crate) async fn transcode_bytes( pub(crate) async fn thumbnail( store: S, from: S::Identifier, - input_format: VideoFormat, + input_format: InternalVideoFormat, format: ThumbnailFormat, ) -> Result { - let input_file = crate::tmp_file::tmp_file(Some(input_format.to_file_extension())); + let input_file = crate::tmp_file::tmp_file(Some(input_format.file_extension())); let input_file_str = input_file.to_str().ok_or(FfMpegError::Path)?; crate::store::file_store::safe_create_parent(&input_file) .await diff --git a/src/ffmpeg/ffprobe_6_0_apng_details.json b/src/ffmpeg/ffprobe_6_0_apng_details.json new file mode 100644 index 0000000..8deac89 --- /dev/null +++ b/src/ffmpeg/ffprobe_6_0_apng_details.json @@ -0,0 +1,15 @@ +{ + "programs": [ + + ], + "streams": [ + { + "width": 112, + "height": 112, + "nb_read_frames": "27" + } + ], + "format": { + "format_name": "apng" + } +} diff --git a/src/ffmpeg/tests.rs b/src/ffmpeg/tests.rs index 764518d..1fde9ad 100644 --- a/src/ffmpeg/tests.rs +++ b/src/ffmpeg/tests.rs @@ -1,7 +1,16 @@ use super::{Details, DetailsOutput, PixelFormatOutput}; -fn details_tests() -> [(&'static str, Option
); 9] { +fn details_tests() -> [(&'static str, Option
); 10] { [ + ( + "apng", + Some(Details { + mime_type: crate::formats::mimes::image_apng(), + width: 112, + height: 112, + frames: Some(27), + }), + ), ("avif", None), ( "gif", @@ -17,7 +26,7 @@ fn details_tests() -> [(&'static str, Option
); 9] { ( "mp4", Some(Details { - mime_type: crate::magick::video_mp4(), + mime_type: crate::formats::mimes::video_mp4(), width: 852, height: 480, frames: Some(35364), @@ -27,7 +36,7 @@ fn details_tests() -> [(&'static str, Option
); 9] { ( "webm", Some(Details { - mime_type: crate::magick::video_webm(), + mime_type: crate::formats::mimes::video_webm(), width: 640, height: 480, frames: Some(34650), @@ -36,7 +45,7 @@ fn details_tests() -> [(&'static str, Option
); 9] { ( "webm_av1", Some(Details { - mime_type: crate::magick::video_webm(), + mime_type: crate::formats::mimes::video_webm(), width: 112, height: 112, frames: Some(27), diff --git a/src/formats.rs b/src/formats.rs index a18bb36..7c69cd7 100644 --- a/src/formats.rs +++ b/src/formats.rs @@ -1,558 +1,100 @@ -fn image_apng() -> mime::Mime { - "image/apng".parse().unwrap() -} +mod animation; +mod image; +pub(crate) mod mimes; +mod video; -fn image_avif() -> mime::Mime { - "image/avif".parse().unwrap() -} +use std::str::FromStr; -fn image_jxl() -> mime::Mime { - "image/jxl".parse().unwrap() -} - -fn image_webp() -> mime::Mime { - "image/webp".parse().unwrap() -} - -fn video_mp4() -> mime::Mime { - "video/mp4".parse().unwrap() -} - -fn video_webm() -> mime::Mime { - "video/webm".parse().unwrap() -} +pub(crate) use animation::{AnimationFormat, AnimationInput, AnimationOutput}; +pub(crate) use image::{ImageFormat, ImageInput, ImageOutput}; +pub(crate) use video::{InternalVideoFormat, OutputVideoFormat, VideoFormat}; #[derive(Clone, Debug)] pub(crate) struct PrescribedFormats { - image: Option, - animation: Option, - video: Option, - allow_audio: bool, + pub(crate) image: Option, + pub(crate) animation: Option, + pub(crate) video: Option, + pub(crate) allow_audio: bool, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum ImageFormat { - #[serde(rename = "avif")] - Avif, - #[serde(rename = "png")] - Png, - #[serde(rename = "jpeg")] - Jpeg, - #[serde(rename = "jxl")] - Jxl, - #[serde(rename = "webp")] - Webp, +#[derive(Clone, Debug)] +pub(crate) enum InputFile { + Image(ImageInput), + Animation(AnimationInput), + Video(VideoFormat), } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum AnimationFormat { +#[derive(Clone, Debug)] +pub(crate) enum OutputFile { + Image(ImageOutput), + Animation(AnimationOutput), + Video(OutputVideoFormat), +} + +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) enum InternalFormat { + Image(ImageFormat), + Animation(AnimationFormat), + Video(InternalVideoFormat), +} + +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +pub(crate) enum ProcessableFormat { + Image(ImageFormat), + Animation(AnimationFormat), +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] +pub(crate) enum InputProcessableFormat { #[serde(rename = "apng")] Apng, #[serde(rename = "avif")] Avif, #[serde(rename = "gif")] Gif, + #[serde(rename = "jpeg")] + Jpeg, + #[serde(rename = "jxl")] + Jxl, + #[serde(rename = "png")] + Png, #[serde(rename = "webp")] Webp, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) enum VideoFormat { - Mp4, - Webp { alpha: bool }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum VideoCodec { - #[serde(rename = "av1")] - Av1, - #[serde(rename = "h264")] - H264, - #[serde(rename = "h265")] - H265, - #[serde(rename = "vp8")] - Vp8, - #[serde(rename = "vp9")] - Vp9, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum AudioCodec { - #[serde(rename = "aac")] - Aac, - #[serde(rename = "opus")] - Opus, - #[serde(rename = "vorbis")] - Vorbis, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) enum OutputVideoFormat { - Mp4 { - video_codec: Mp4Codec, - audio_codec: Option, - }, - Webm { - video_codec: WebmCodec, - audio_codec: Option, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum Mp4Codec { - #[serde(rename = "h264")] - H264, - #[serde(rename = "h265")] - H265, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] -pub(crate) enum WebmAlphaCodec { - #[serde(rename = "vp8")] - Vp8, - #[serde(rename = "vp9")] - Vp9, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct AlphaCodec { - pub(crate) alpha: bool, - pub(crate) codec: WebmAlphaCodec, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) enum WebmCodec { - Av1, - Alpha(AlphaCodec), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) enum Mp4AudioCodec { - Aac, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) enum WebmAudioCodec { - Opus, - Vorbis, -} - -#[derive(Clone, Debug)] -pub(crate) enum InputFile { - Image { - format: ImageFormat, - needs_reorient: bool, - }, - Animation(AnimationFormat), - Video(VideoFormat), -} - -#[derive(Clone, Debug)] -pub(crate) enum OutputFile { - Image { - format: ImageFormat, - needs_transcode: bool, - }, - Animation { - format: AnimationFormat, - needs_transcode: bool, - }, - Video(OutputVideoFormat), -} - impl InputFile { const fn file_extension(&self) -> &'static str { match self { - Self::Image { format, .. } => format.file_extension(), - Self::Animation(format) => format.file_extension(), + Self::Image(ImageInput { format, .. }) => format.file_extension(), + Self::Animation(AnimationInput { format }) => format.file_extension(), Self::Video(format) => format.file_extension(), } } const fn build_output(&self, prescribed: &PrescribedFormats) -> OutputFile { match (self, prescribed) { - ( - InputFile::Image { - format, - needs_reorient, - }, - PrescribedFormats { - image: Some(prescribed), - .. - }, - ) => OutputFile::Image { - format: *prescribed, - needs_transcode: *needs_reorient || !format.const_eq(*prescribed), - }, - ( - InputFile::Animation(format), - PrescribedFormats { - animation: Some(prescribed), - .. - }, - ) => OutputFile::Animation { - format: *prescribed, - needs_transcode: !format.const_eq(*prescribed), - }, - ( - InputFile::Video(VideoFormat::Webp { alpha }), - PrescribedFormats { - video: - Some(OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { codec, .. }), - audio_codec, - }), - .. - }, - ) => OutputFile::Video(OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: *alpha, - codec: *codec, - }), - audio_codec: *audio_codec, - }), - ( - InputFile::Video(_), - PrescribedFormats { - video: Some(prescribed), - .. - }, - ) => OutputFile::Video(*prescribed), - ( - InputFile::Image { - format, - needs_reorient, - }, - PrescribedFormats { image: None, .. }, - ) => OutputFile::Image { - format: *format, - needs_transcode: *needs_reorient, - }, - ( - InputFile::Animation(input), - PrescribedFormats { - animation: None, .. - }, - ) => OutputFile::Animation { - format: *input, - needs_transcode: false, - }, + (InputFile::Image(input), PrescribedFormats { image, .. }) => { + OutputFile::Image(input.build_output(*image)) + } + (InputFile::Animation(input), PrescribedFormats { animation, .. }) => { + OutputFile::Animation(input.build_output(*animation)) + } ( InputFile::Video(input), PrescribedFormats { - video: None, - allow_audio: true, - .. + video, allow_audio, .. }, - ) => match input { - VideoFormat::Mp4 => OutputFile::Video(OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H264, - audio_codec: Some(Mp4AudioCodec::Aac), - }), - VideoFormat::Webp { alpha } => OutputFile::Video(OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: *alpha, - codec: WebmAlphaCodec::Vp9, - }), - audio_codec: Some(WebmAudioCodec::Opus), - }), - }, - ( - InputFile::Video(input), - PrescribedFormats { - video: None, - allow_audio: false, - .. - }, - ) => match input { - VideoFormat::Mp4 => OutputFile::Video(OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H264, - audio_codec: None, - }), - VideoFormat::Webp { alpha } => OutputFile::Video(OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: *alpha, - codec: WebmAlphaCodec::Vp9, - }), - audio_codec: None, - }), - }, - } - } -} - -impl ImageFormat { - const fn const_eq(self, rhs: Self) -> bool { - match (self, rhs) { - (Self::Avif, Self::Avif) - | (Self::Jpeg, Self::Jpeg) - | (Self::Jxl, Self::Jxl) - | (Self::Png, Self::Png) - | (Self::Webp, Self::Webp) => true, - (Self::Avif, _) - | (Self::Jpeg, _) - | (Self::Jxl, _) - | (Self::Png, _) - | (Self::Webp, _) => false, + ) => OutputFile::Video(input.build_output(*video, *allow_audio)), } } - const fn file_extension(self) -> &'static str { + pub(crate) const fn internal_format(&self) -> InternalFormat { match self { - Self::Avif => ".avif", - Self::Jpeg => ".jpeg", - Self::Jxl => ".jxl", - Self::Png => ".png", - Self::Webp => ".webp", - } - } - - const fn magick_format(self) -> &'static str { - match self { - Self::Avif => "AVIF", - Self::Jpeg => "JPEG", - Self::Jxl => "JXL", - Self::Png => "PNG", - Self::Webp => "Webp", - } - } - - fn media_type(self) -> mime::Mime { - match self { - Self::Avif => image_avif(), - Self::Jpeg => mime::IMAGE_JPEG, - Self::Jxl => image_jxl(), - Self::Png => mime::IMAGE_PNG, - Self::Webp => image_webp(), - } - } -} - -impl AnimationFormat { - const fn const_eq(self, rhs: Self) -> bool { - match (self, rhs) { - (Self::Apng, Self::Apng) - | (Self::Avif, Self::Avif) - | (Self::Gif, Self::Gif) - | (Self::Webp, Self::Webp) => true, - (Self::Apng, _) | (Self::Avif, _) | (Self::Gif, _) | (Self::Webp, _) => false, - } - } - - const fn file_extension(self) -> &'static str { - match self { - Self::Apng => ".apng", - Self::Avif => ".avif", - Self::Gif => ".gif", - Self::Webp => ".webp", - } - } - - const fn magick_format(self) -> &'static str { - match self { - Self::Apng => "APNG", - Self::Avif => "AVIF", - Self::Gif => "GIF", - Self::Webp => "WEBP", - } - } - - fn media_type(self) -> mime::Mime { - match self { - Self::Apng => image_apng(), - Self::Avif => image_avif(), - Self::Gif => mime::IMAGE_GIF, - Self::Webp => image_webp(), - } - } -} - -impl VideoFormat { - const fn file_extension(self) -> &'static str { - match self { - Self::Mp4 => ".mp4", - Self::Webp { .. } => ".webm", - } - } -} - -impl OutputVideoFormat { - const fn from_parts( - video_codec: VideoCodec, - audio_codec: Option, - allow_audio: bool, - ) -> Self { - match (video_codec, audio_codec) { - (VideoCodec::Av1, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Av1, - audio_codec: Some(WebmAudioCodec::Vorbis), - }, - (VideoCodec::Av1, _) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Av1, - audio_codec: Some(WebmAudioCodec::Opus), - }, - (VideoCodec::Av1, _) => OutputVideoFormat::Webm { - video_codec: WebmCodec::Av1, - audio_codec: None, - }, - (VideoCodec::H264, _) if allow_audio => OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H264, - audio_codec: Some(Mp4AudioCodec::Aac), - }, - (VideoCodec::H264, _) => OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H264, - audio_codec: None, - }, - (VideoCodec::H265, _) if allow_audio => OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H265, - audio_codec: Some(Mp4AudioCodec::Aac), - }, - (VideoCodec::H265, _) => OutputVideoFormat::Mp4 { - video_codec: Mp4Codec::H265, - audio_codec: None, - }, - (VideoCodec::Vp8, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp8, - }), - audio_codec: Some(WebmAudioCodec::Vorbis), - }, - (VideoCodec::Vp8, _) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp8, - }), - audio_codec: Some(WebmAudioCodec::Opus), - }, - (VideoCodec::Vp8, _) => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp8, - }), - audio_codec: None, - }, - (VideoCodec::Vp9, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp9, - }), - audio_codec: Some(WebmAudioCodec::Vorbis), - }, - (VideoCodec::Vp9, _) if allow_audio => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp9, - }), - audio_codec: Some(WebmAudioCodec::Opus), - }, - (VideoCodec::Vp9, _) => OutputVideoFormat::Webm { - video_codec: WebmCodec::Alpha(AlphaCodec { - alpha: false, - codec: WebmAlphaCodec::Vp9, - }), - audio_codec: None, - }, - } - } - - const fn file_extension(self) -> &'static str { - match self { - Self::Mp4 { .. } => ".mp4", - Self::Webm { .. } => ".webm", - } - } - - const fn ffmpeg_format(self) -> &'static str { - match self { - Self::Mp4 { .. } => "mp4", - Self::Webm { .. } => "webm", - } - } - - const fn ffmpeg_video_codec(self) -> &'static str { - match self { - Self::Mp4 { video_codec, .. } => video_codec.ffmpeg_codec(), - Self::Webm { video_codec, .. } => video_codec.ffmpeg_codec(), - } - } - - const fn ffmpeg_audio_codec(self) -> Option<&'static str> { - match self { - Self::Mp4 { - audio_codec: Some(audio_codec), - .. - } => Some(audio_codec.ffmpeg_codec()), - Self::Webm { - audio_codec: Some(audio_codec), - .. - } => Some(audio_codec.ffmpeg_codec()), - _ => None, - } - } - - const fn pix_fmt(self) -> &'static str { - match self { - Self::Mp4 { .. } => "yuv420p", - Self::Webm { video_codec, .. } => video_codec.pix_fmt(), - } - } - - fn media_type(self) -> mime::Mime { - match self { - Self::Mp4 { .. } => video_mp4(), - Self::Webm { .. } => video_webm(), - } - } -} - -impl Mp4Codec { - const fn ffmpeg_codec(self) -> &'static str { - match self { - Self::H264 => "h264", - Self::H265 => "hevc", - } - } -} - -impl WebmAlphaCodec { - const fn ffmpeg_codec(self) -> &'static str { - match self { - Self::Vp8 => "vp8", - Self::Vp9 => "vp9", - } - } -} - -impl WebmCodec { - const fn ffmpeg_codec(self) -> &'static str { - match self { - Self::Av1 => "av1", - Self::Alpha(AlphaCodec { codec, .. }) => codec.ffmpeg_codec(), - } - } - - const fn pix_fmt(self) -> &'static str { - match self { - Self::Alpha(AlphaCodec { alpha: true, .. }) => "yuva420p", - _ => "yuv420p", - } - } -} - -impl Mp4AudioCodec { - const fn ffmpeg_codec(self) -> &'static str { - match self { - Self::Aac => "aac", - } - } -} - -impl WebmAudioCodec { - const fn ffmpeg_codec(self) -> &'static str { - match self { - Self::Opus => "libopus", - Self::Vorbis => "vorbis", + Self::Image(ImageInput { format, .. }) => InternalFormat::Image(*format), + Self::Animation(AnimationInput { format }) => InternalFormat::Animation(*format), + Self::Video(format) => InternalFormat::Video(format.internal_format()), } } } @@ -560,17 +102,95 @@ impl WebmAudioCodec { impl OutputFile { const fn file_extension(&self) -> &'static str { match self { - Self::Image { format, .. } => format.file_extension(), - Self::Animation { format, .. } => format.file_extension(), + Self::Image(ImageOutput { format, .. }) => format.file_extension(), + Self::Animation(AnimationOutput { format, .. }) => format.file_extension(), Self::Video(format) => format.file_extension(), } } - fn media_type(&self) -> mime::Mime { + pub(crate) const fn internal_format(&self) -> InternalFormat { match self { - Self::Image { format, .. } => format.media_type(), - Self::Animation { format, .. } => format.media_type(), - Self::Video(format) => format.media_type(), + Self::Image(ImageOutput { format, .. }) => InternalFormat::Image(*format), + Self::Animation(AnimationOutput { format, .. }) => InternalFormat::Animation(*format), + Self::Video(format) => InternalFormat::Video(format.internal_format()), + } + } +} + +impl InternalFormat { + pub(crate) fn media_type(self) -> mime::Mime { + match self { + Self::Image(format) => format.media_type(), + Self::Animation(format) => format.media_type(), + Self::Video(format) => format.media_type(), + } + } + + pub(crate) const fn file_extension(self) -> &'static str { + match self { + Self::Image(format) => format.file_extension(), + Self::Animation(format) => format.file_extension(), + Self::Video(format) => format.file_extension(), + } + } + + pub(crate) const fn processable_format(self) -> Option { + match self { + Self::Image(format) => Some(ProcessableFormat::Image(format)), + Self::Animation(format) => Some(ProcessableFormat::Animation(format)), + Self::Video(_) => None, + } + } +} + +impl ProcessableFormat { + pub(crate) const fn file_extension(self) -> &'static str { + match self { + Self::Image(format) => format.file_extension(), + Self::Animation(format) => format.file_extension(), + } + } + + pub(crate) const fn coalesce(self) -> bool { + matches!(self, Self::Animation(_)) + } + + pub(crate) fn magick_format(self) -> &'static str { + match self { + Self::Image(format) => format.magick_format(), + Self::Animation(format) => format.magick_format(), + } + } +} + +impl FromStr for InputProcessableFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "apng" => Ok(Self::Apng), + "avif" => Ok(Self::Avif), + "gif" => Ok(Self::Gif), + "jpeg" => Ok(Self::Jpeg), + "jpg" => Ok(Self::Jpeg), + "jxl" => Ok(Self::Jxl), + "png" => Ok(Self::Png), + "webp" => Ok(Self::Webp), + otherwise => Err(format!("Invalid format: {otherwise}")), + } + } +} + +impl std::fmt::Display for InputProcessableFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Apng => write!(f, "apng"), + Self::Avif => write!(f, "avif"), + Self::Gif => write!(f, "gif"), + Self::Jpeg => write!(f, "jpeg"), + Self::Jxl => write!(f, "jxl"), + Self::Png => write!(f, "png"), + Self::Webp => write!(f, "webp"), } } } diff --git a/src/formats/animation.rs b/src/formats/animation.rs new file mode 100644 index 0000000..66841ab --- /dev/null +++ b/src/formats/animation.rs @@ -0,0 +1,84 @@ +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum AnimationFormat { + #[serde(rename = "apng")] + Apng, + #[serde(rename = "avif")] + Avif, + #[serde(rename = "gif")] + Gif, + #[serde(rename = "webp")] + Webp, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] +pub(crate) struct AnimationInput { + pub(crate) format: AnimationFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] +pub(crate) struct AnimationOutput { + pub(crate) format: AnimationFormat, + pub(crate) needs_transcode: bool, +} + +impl AnimationInput { + pub(crate) const fn build_output( + &self, + prescribed: Option, + ) -> AnimationOutput { + if let Some(prescribed) = prescribed { + let needs_transcode = !self.format.const_eq(prescribed); + + return AnimationOutput { + format: prescribed, + needs_transcode, + }; + } + + AnimationOutput { + format: self.format, + needs_transcode: false, + } + } +} + +impl AnimationFormat { + const fn const_eq(self, rhs: Self) -> bool { + match (self, rhs) { + (Self::Apng, Self::Apng) + | (Self::Avif, Self::Avif) + | (Self::Gif, Self::Gif) + | (Self::Webp, Self::Webp) => true, + (Self::Apng, _) | (Self::Avif, _) | (Self::Gif, _) | (Self::Webp, _) => false, + } + } + + pub(super) const fn file_extension(self) -> &'static str { + match self { + Self::Apng => ".apng", + Self::Avif => ".avif", + Self::Gif => ".gif", + Self::Webp => ".webp", + } + } + + pub(crate) const fn magick_format(self) -> &'static str { + match self { + Self::Apng => "APNG", + Self::Avif => "AVIF", + Self::Gif => "GIF", + Self::Webp => "WEBP", + } + } + + pub(super) fn media_type(self) -> mime::Mime { + match self { + Self::Apng => super::mimes::image_apng(), + Self::Avif => super::mimes::image_avif(), + Self::Gif => mime::IMAGE_GIF, + Self::Webp => super::mimes::image_webp(), + } + } +} diff --git a/src/formats/image.rs b/src/formats/image.rs new file mode 100644 index 0000000..1485e60 --- /dev/null +++ b/src/formats/image.rs @@ -0,0 +1,92 @@ +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum ImageFormat { + #[serde(rename = "avif")] + Avif, + #[serde(rename = "png")] + Png, + #[serde(rename = "jpeg")] + Jpeg, + #[serde(rename = "jxl")] + Jxl, + #[serde(rename = "webp")] + Webp, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] +pub(crate) struct ImageInput { + pub(crate) format: ImageFormat, + pub(crate) needs_reorient: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] +pub(crate) struct ImageOutput { + pub(crate) format: ImageFormat, + pub(crate) needs_transcode: bool, +} + +impl ImageInput { + pub(crate) const fn build_output(self, prescribed: Option) -> ImageOutput { + if let Some(prescribed) = prescribed { + let needs_transcode = self.needs_reorient || !self.format.const_eq(prescribed); + + return ImageOutput { + format: prescribed, + needs_transcode, + }; + } + + ImageOutput { + format: self.format, + needs_transcode: self.needs_reorient, + } + } +} + +impl ImageFormat { + pub(super) const fn const_eq(self, rhs: Self) -> bool { + match (self, rhs) { + (Self::Avif, Self::Avif) + | (Self::Jpeg, Self::Jpeg) + | (Self::Jxl, Self::Jxl) + | (Self::Png, Self::Png) + | (Self::Webp, Self::Webp) => true, + (Self::Avif, _) + | (Self::Jpeg, _) + | (Self::Jxl, _) + | (Self::Png, _) + | (Self::Webp, _) => false, + } + } + + pub(super) const fn file_extension(self) -> &'static str { + match self { + Self::Avif => ".avif", + Self::Jpeg => ".jpeg", + Self::Jxl => ".jxl", + Self::Png => ".png", + Self::Webp => ".webp", + } + } + + pub(crate) const fn magick_format(self) -> &'static str { + match self { + Self::Avif => "AVIF", + Self::Jpeg => "JPEG", + Self::Jxl => "JXL", + Self::Png => "PNG", + Self::Webp => "Webp", + } + } + + pub(super) fn media_type(self) -> mime::Mime { + match self { + Self::Avif => super::mimes::image_avif(), + Self::Jpeg => mime::IMAGE_JPEG, + Self::Jxl => super::mimes::image_jxl(), + Self::Png => mime::IMAGE_PNG, + Self::Webp => super::mimes::image_webp(), + } + } +} diff --git a/src/formats/mimes.rs b/src/formats/mimes.rs new file mode 100644 index 0000000..25b9f96 --- /dev/null +++ b/src/formats/mimes.rs @@ -0,0 +1,23 @@ +pub(crate) fn image_apng() -> mime::Mime { + "image/apng".parse().unwrap() +} + +pub(crate) fn image_avif() -> mime::Mime { + "image/avif".parse().unwrap() +} + +pub(crate) fn image_jxl() -> mime::Mime { + "image/jxl".parse().unwrap() +} + +pub(crate) fn image_webp() -> mime::Mime { + "image/webp".parse().unwrap() +} + +pub(crate) fn video_mp4() -> mime::Mime { + "video/mp4".parse().unwrap() +} + +pub(crate) fn video_webm() -> mime::Mime { + "video/webm".parse().unwrap() +} diff --git a/src/formats/video.rs b/src/formats/video.rs new file mode 100644 index 0000000..e4c1e5b --- /dev/null +++ b/src/formats/video.rs @@ -0,0 +1,372 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum VideoFormat { + Mp4, + Webm { alpha: bool }, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum OutputVideoFormat { + Mp4 { + video_codec: Mp4Codec, + audio_codec: Option, + }, + Webm { + video_codec: WebmCodec, + audio_codec: Option, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize)] +pub(crate) enum VideoCodec { + #[serde(rename = "av1")] + Av1, + #[serde(rename = "h264")] + H264, + #[serde(rename = "h265")] + H265, + #[serde(rename = "vp8")] + Vp8, + #[serde(rename = "vp9")] + Vp9, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum AudioCodec { + #[serde(rename = "aac")] + Aac, + #[serde(rename = "opus")] + Opus, + #[serde(rename = "vorbis")] + Vorbis, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum Mp4Codec { + #[serde(rename = "h264")] + H264, + #[serde(rename = "h265")] + H265, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum WebmAlphaCodec { + #[serde(rename = "vp8")] + Vp8, + #[serde(rename = "vp9")] + Vp9, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) struct AlphaCodec { + pub(crate) alpha: bool, + pub(crate) codec: WebmAlphaCodec, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum WebmCodec { + Av1, + Alpha(AlphaCodec), +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum Mp4AudioCodec { + Aac, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum WebmAudioCodec { + Opus, + Vorbis, +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, +)] +pub(crate) enum InternalVideoFormat { + Mp4, + Webm, +} + +impl VideoFormat { + pub(crate) const fn file_extension(self) -> &'static str { + match self { + Self::Mp4 => ".mp4", + Self::Webm { .. } => ".webm", + } + } + + pub(crate) const fn ffmpeg_format(self) -> &'static str { + match self { + Self::Mp4 => "mp4", + Self::Webm { .. } => "webm", + } + } + + pub(crate) const fn internal_format(self) -> InternalVideoFormat { + match self { + Self::Mp4 => InternalVideoFormat::Mp4, + Self::Webm { .. } => InternalVideoFormat::Webm, + } + } + + pub(crate) const fn build_output( + self, + prescribed: Option, + allow_audio: bool, + ) -> OutputVideoFormat { + match (prescribed, self) { + ( + Some(OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { codec, .. }), + audio_codec, + }), + Self::Webm { alpha }, + ) => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { alpha, codec }), + audio_codec, + }, + (Some(prescribed), _) => prescribed, + (None, format) => match format { + VideoFormat::Mp4 => OutputVideoFormat::Mp4 { + video_codec: Mp4Codec::H264, + audio_codec: if allow_audio { + Some(Mp4AudioCodec::Aac) + } else { + None + }, + }, + VideoFormat::Webm { alpha } => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha, + codec: WebmAlphaCodec::Vp9, + }), + audio_codec: if allow_audio { + Some(WebmAudioCodec::Opus) + } else { + None + }, + }, + }, + } + } +} + +impl OutputVideoFormat { + pub(super) const fn from_parts( + video_codec: VideoCodec, + audio_codec: Option, + allow_audio: bool, + ) -> Self { + match (video_codec, audio_codec) { + (VideoCodec::Av1, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Av1, + audio_codec: Some(WebmAudioCodec::Vorbis), + }, + (VideoCodec::Av1, _) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Av1, + audio_codec: Some(WebmAudioCodec::Opus), + }, + (VideoCodec::Av1, _) => OutputVideoFormat::Webm { + video_codec: WebmCodec::Av1, + audio_codec: None, + }, + (VideoCodec::H264, _) if allow_audio => OutputVideoFormat::Mp4 { + video_codec: Mp4Codec::H264, + audio_codec: Some(Mp4AudioCodec::Aac), + }, + (VideoCodec::H264, _) => OutputVideoFormat::Mp4 { + video_codec: Mp4Codec::H264, + audio_codec: None, + }, + (VideoCodec::H265, _) if allow_audio => OutputVideoFormat::Mp4 { + video_codec: Mp4Codec::H265, + audio_codec: Some(Mp4AudioCodec::Aac), + }, + (VideoCodec::H265, _) => OutputVideoFormat::Mp4 { + video_codec: Mp4Codec::H265, + audio_codec: None, + }, + (VideoCodec::Vp8, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp8, + }), + audio_codec: Some(WebmAudioCodec::Vorbis), + }, + (VideoCodec::Vp8, _) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp8, + }), + audio_codec: Some(WebmAudioCodec::Opus), + }, + (VideoCodec::Vp8, _) => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp8, + }), + audio_codec: None, + }, + (VideoCodec::Vp9, Some(AudioCodec::Vorbis)) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp9, + }), + audio_codec: Some(WebmAudioCodec::Vorbis), + }, + (VideoCodec::Vp9, _) if allow_audio => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp9, + }), + audio_codec: Some(WebmAudioCodec::Opus), + }, + (VideoCodec::Vp9, _) => OutputVideoFormat::Webm { + video_codec: WebmCodec::Alpha(AlphaCodec { + alpha: false, + codec: WebmAlphaCodec::Vp9, + }), + audio_codec: None, + }, + } + } + + pub(super) const fn file_extension(self) -> &'static str { + match self { + Self::Mp4 { .. } => ".mp4", + Self::Webm { .. } => ".webm", + } + } + + pub(crate) const fn ffmpeg_format(self) -> &'static str { + match self { + Self::Mp4 { .. } => "mp4", + Self::Webm { .. } => "webm", + } + } + + pub(crate) const fn ffmpeg_video_codec(self) -> &'static str { + match self { + Self::Mp4 { video_codec, .. } => video_codec.ffmpeg_codec(), + Self::Webm { video_codec, .. } => video_codec.ffmpeg_codec(), + } + } + + pub(crate) const fn ffmpeg_audio_codec(self) -> Option<&'static str> { + match self { + Self::Mp4 { + audio_codec: Some(audio_codec), + .. + } => Some(audio_codec.ffmpeg_codec()), + Self::Webm { + audio_codec: Some(audio_codec), + .. + } => Some(audio_codec.ffmpeg_codec()), + _ => None, + } + } + + pub(crate) const fn pix_fmt(self) -> &'static str { + match self { + Self::Mp4 { .. } => "yuv420p", + Self::Webm { video_codec, .. } => video_codec.pix_fmt(), + } + } + + pub(super) fn media_type(self) -> mime::Mime { + match self { + Self::Mp4 { .. } => super::mimes::video_mp4(), + Self::Webm { .. } => super::mimes::video_webm(), + } + } + + pub(crate) fn internal_format(self) -> InternalVideoFormat { + match self { + Self::Mp4 { .. } => InternalVideoFormat::Mp4, + Self::Webm { .. } => InternalVideoFormat::Webm, + } + } +} + +impl Mp4Codec { + const fn ffmpeg_codec(self) -> &'static str { + match self { + Self::H264 => "h264", + Self::H265 => "hevc", + } + } +} + +impl WebmAlphaCodec { + const fn ffmpeg_codec(self) -> &'static str { + match self { + Self::Vp8 => "vp8", + Self::Vp9 => "vp9", + } + } +} + +impl WebmCodec { + const fn ffmpeg_codec(self) -> &'static str { + match self { + Self::Av1 => "av1", + Self::Alpha(AlphaCodec { codec, .. }) => codec.ffmpeg_codec(), + } + } + + const fn pix_fmt(self) -> &'static str { + match self { + Self::Alpha(AlphaCodec { alpha: true, .. }) => "yuva420p", + _ => "yuv420p", + } + } +} + +impl Mp4AudioCodec { + const fn ffmpeg_codec(self) -> &'static str { + match self { + Self::Aac => "aac", + } + } +} + +impl WebmAudioCodec { + const fn ffmpeg_codec(self) -> &'static str { + match self { + Self::Opus => "libopus", + Self::Vorbis => "vorbis", + } + } +} + +impl InternalVideoFormat { + pub(crate) const fn file_extension(self) -> &'static str { + match self { + Self::Mp4 => ".mp4", + Self::Webm => ".webm", + } + } + + pub(super) fn media_type(self) -> mime::Mime { + match self { + Self::Mp4 => super::mimes::video_mp4(), + Self::Webm => super::mimes::video_webm(), + } + } +} diff --git a/src/generate.rs b/src/generate.rs index c4db291..41ce190 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -1,9 +1,9 @@ use crate::{ concurrent_processor::CancelSafeProcessor, - config::ImageFormat, details::Details, error::{Error, UploadError}, - ffmpeg::{ThumbnailFormat, VideoFormat}, + ffmpeg::ThumbnailFormat, + formats::{InputProcessableFormat, InternalVideoFormat}, repo::{Alias, FullRepo}, store::Store, }; @@ -17,11 +17,11 @@ use tracing::Instrument; pub(crate) async fn generate( repo: &R, store: &S, - format: ImageFormat, + format: InputProcessableFormat, alias: Alias, thumbnail_path: PathBuf, thumbnail_args: Vec, - input_format: Option, + input_format: Option, thumbnail_format: Option, hash: R::Bytes, ) -> Result<(Details, Bytes), Error> { @@ -48,11 +48,11 @@ pub(crate) async fn generate( async fn process( repo: &R, store: &S, - format: ImageFormat, + format: InputProcessableFormat, alias: Alias, thumbnail_path: PathBuf, thumbnail_args: Vec, - input_format: Option, + input_format: Option, thumbnail_format: Option, hash: R::Bytes, ) -> Result<(Details, Bytes), Error> { @@ -71,7 +71,7 @@ async fn process( let reader = crate::ffmpeg::thumbnail( store.clone(), identifier, - input_format.unwrap_or(VideoFormat::Mp4), + input_format.unwrap_or(InternalVideoFormat::Mp4), thumbnail_format.unwrap_or(ThumbnailFormat::Jpeg), ) .await?; @@ -95,7 +95,14 @@ async fn process( drop(permit); - let details = Details::from_bytes(bytes.clone(), format.as_hint()).await?; + let discovery = crate::discover::discover_bytes(bytes.clone()).await?; + + let details = Details::from_parts( + discovery.input.internal_format(), + discovery.width, + discovery.height, + discovery.frames, + ); let identifier = store.save_bytes(bytes.clone()).await?; repo.relate_details(&identifier, &details).await?; diff --git a/src/ingest.rs b/src/ingest.rs index fd53e27..bf5030b 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -2,7 +2,7 @@ use crate::{ bytes_stream::BytesStream, either::Either, error::{Error, UploadError}, - magick::ValidInputType, + formats::{InternalFormat, PrescribedFormats}, repo::{Alias, AliasRepo, AlreadyExists, DeleteToken, FullRepo, HashRepo}, store::Store, CONFIG, @@ -47,7 +47,6 @@ pub(crate) async fn ingest( store: &S, stream: impl Stream> + Unpin + 'static, declared_alias: Option, - should_validate: bool, ) -> Result, Error> where R: FullRepo + 'static, @@ -57,13 +56,22 @@ where let bytes = aggregate(stream).await?; + // TODO: load from config + let prescribed = PrescribedFormats { + image: None, + animation: None, + video: None, + allow_audio: true, + }; + tracing::trace!("Validating bytes"); let (input_type, validated_reader) = - crate::validate::validate_bytes(bytes, &CONFIG.media, should_validate).await?; + crate::validate::validate_bytes(bytes, &prescribed).await?; let processed_reader = if let Some(operations) = CONFIG.media.preprocess_steps() { - if let Some(format) = input_type.to_format() { - let (_, magick_args) = crate::processor::build_chain(operations, format.as_ext())?; + if let Some(format) = input_type.processable_format() { + let (_, magick_args) = + crate::processor::build_chain(operations, format.file_extension())?; let processed_reader = crate::magick::process_image_async_read(validated_reader, magick_args, format)?; @@ -180,9 +188,9 @@ where } #[tracing::instrument(level = "debug", skip(self, hash))] - async fn create_alias(&mut self, hash: &[u8], input_type: ValidInputType) -> Result<(), Error> { + async fn create_alias(&mut self, hash: &[u8], input_type: InternalFormat) -> Result<(), Error> { loop { - let alias = Alias::generate(input_type.as_ext().to_string()); + let alias = Alias::generate(input_type.file_extension().to_string()); if AliasRepo::create(&self.repo, &alias).await?.is_ok() { self.alias = Some(alias.clone()); diff --git a/src/lib.rs b/src/lib.rs index 862a0ef..671c7f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod bytes_stream; mod concurrent_processor; mod config; mod details; +mod discover; mod either; mod error; mod exiftool; @@ -32,6 +33,7 @@ use actix_web::{ web, App, HttpRequest, HttpResponse, HttpResponseBuilder, HttpServer, }; use awc::{Client, Connector}; +use formats::{InputProcessableFormat, ProcessableFormat}; use futures_util::{ stream::{empty, once}, Stream, StreamExt, TryStreamExt, @@ -163,7 +165,7 @@ impl FormData for Upload { let stream = stream.map_err(Error::from); Box::pin( - async move { ingest::ingest(&**repo, &**store, stream, None, true).await } + async move { ingest::ingest(&**repo, &**store, stream, None).await } .instrument(span), ) })), @@ -215,7 +217,6 @@ impl FormData for Import { &**store, stream, Some(Alias::from_existing(&filename)), - !CONFIG.media.skip_validate_imports, ) .await } @@ -371,7 +372,7 @@ async fn upload_backgrounded( .expect("Identifier exists") .to_bytes()?; - queue::queue_ingest(&repo, identifier, upload_id, None, true).await?; + queue::queue_ingest(&repo, identifier, upload_id, None).await?; files.push(serde_json::json!({ "upload_id": upload_id.to_string(), @@ -470,7 +471,7 @@ async fn do_download_inline( repo: web::Data, store: web::Data, ) -> Result { - let mut session = ingest::ingest(&repo, &store, stream, None, true).await?; + let mut session = ingest::ingest(&repo, &store, stream, None).await?; let alias = session.alias().expect("alias should exist").to_owned(); let delete_token = session.delete_token().await?; @@ -503,7 +504,7 @@ async fn do_download_backgrounded( .expect("Identifier exists") .to_bytes()?; - queue::queue_ingest(&repo, identifier, upload_id, None, true).await?; + queue::queue_ingest(&repo, identifier, upload_id, None).await?; backgrounded.disarm(); @@ -536,7 +537,7 @@ type ProcessQuery = Vec<(String, String)>; fn prepare_process( query: web::Query, ext: &str, -) -> Result<(ImageFormat, Alias, PathBuf, Vec), Error> { +) -> Result<(InputProcessableFormat, Alias, PathBuf, Vec), Error> { let (alias, operations) = query .into_inner() @@ -562,12 +563,11 @@ fn prepare_process( .collect::>(); let format = ext - .parse::() + .parse::() .map_err(|_| UploadError::UnsupportedProcessExtension)?; - let ext = format.to_string(); - - let (thumbnail_path, thumbnail_args) = self::processor::build_chain(&operations, &ext)?; + let (thumbnail_path, thumbnail_args) = + self::processor::build_chain(&operations, &format.to_string())?; Ok((format, alias, thumbnail_path, thumbnail_args)) } diff --git a/src/magick.rs b/src/magick.rs index 5e3eea5..b8135e8 100644 --- a/src/magick.rs +++ b/src/magick.rs @@ -3,6 +3,7 @@ mod tests; use crate::{ config::{ImageFormat, VideoCodec}, + formats::ProcessableFormat, process::{Process, ProcessError}, repo::Alias, store::Store, @@ -416,14 +417,26 @@ pub(crate) async fn input_type_bytes( Ok((details, input_type)) } -fn process_image(process_args: Vec, format: ImageFormat) -> Result { +fn process_image( + process_args: Vec, + format: ProcessableFormat, +) -> Result { let command = "magick"; let convert_args = ["convert", "-"]; - let last_arg = format!("{}:-", format.as_magick_format()); + let last_arg = format!("{}:-", format.magick_format()); - let mut args = Vec::with_capacity(process_args.len() + 3); + let len = if format.coalesce() { + process_args.len() + 4 + } else { + process_args.len() + 3 + }; + + let mut args = Vec::with_capacity(len); args.extend_from_slice(&convert_args[..]); args.extend(process_args.iter().map(|s| s.as_str())); + if format.coalesce() { + args.push("-coalesce"); + } args.push(&last_arg); Process::run(command, &args) @@ -433,7 +446,7 @@ pub(crate) fn process_image_store_read( store: S, identifier: S::Identifier, args: Vec, - format: ImageFormat, + format: ProcessableFormat, ) -> Result { Ok(process_image(args, format) .map_err(MagickError::Process)? @@ -443,7 +456,7 @@ pub(crate) fn process_image_store_read( pub(crate) fn process_image_async_read( async_read: A, args: Vec, - format: ImageFormat, + format: ProcessableFormat, ) -> Result { Ok(process_image(args, format) .map_err(MagickError::Process)? diff --git a/src/queue.rs b/src/queue.rs index ba0dea3..4ed5b0e 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,6 +1,6 @@ use crate::{ - config::ImageFormat, error::Error, + formats::ProcessableFormat, repo::{ Alias, AliasRepo, DeleteToken, FullRepo, HashRepo, IdentifierRepo, QueueRepo, UploadId, }, @@ -67,10 +67,9 @@ enum Process { identifier: Base64Bytes, upload_id: Serde, declared_alias: Option>, - should_validate: bool, }, Generate { - target_format: ImageFormat, + target_format: ProcessableFormat, source: Serde, process_path: PathBuf, process_args: Vec, @@ -128,13 +127,11 @@ pub(crate) async fn queue_ingest( identifier: Vec, upload_id: UploadId, declared_alias: Option, - should_validate: bool, ) -> Result<(), Error> { let job = serde_json::to_vec(&Process::Ingest { identifier: Base64Bytes(identifier), declared_alias: declared_alias.map(Serde::new), upload_id: Serde::new(upload_id), - should_validate, })?; repo.push(PROCESS_QUEUE, job.into()).await?; Ok(()) @@ -142,7 +139,7 @@ pub(crate) async fn queue_ingest( pub(crate) async fn queue_generate( repo: &R, - target_format: ImageFormat, + target_format: ProcessableFormat, source: Alias, process_path: PathBuf, process_args: Vec, diff --git a/src/queue/process.rs b/src/queue/process.rs index db040ec..b26cced 100644 --- a/src/queue/process.rs +++ b/src/queue/process.rs @@ -1,6 +1,6 @@ use crate::{ - config::ImageFormat, error::Error, + formats::InputProcessableFormat, ingest::Session, queue::{Base64Bytes, LocalBoxFuture, Process}, repo::{Alias, DeleteToken, FullRepo, UploadId, UploadResult}, @@ -26,7 +26,6 @@ where identifier: Base64Bytes(identifier), upload_id, declared_alias, - should_validate, } => { process_ingest( repo, @@ -34,7 +33,6 @@ where identifier, Serde::into_inner(upload_id), declared_alias.map(Serde::into_inner), - should_validate, ) .await? } @@ -71,7 +69,6 @@ async fn process_ingest( unprocessed_identifier: Vec, upload_id: UploadId, declared_alias: Option, - should_validate: bool, ) -> Result<(), Error> where R: FullRepo + 'static, @@ -85,8 +82,7 @@ where .await? .map_err(Error::from); - let session = - crate::ingest::ingest(repo, store, stream, declared_alias, should_validate).await?; + let session = crate::ingest::ingest(repo, store, stream, declared_alias).await?; let token = session.delete_token().await?; @@ -120,7 +116,7 @@ where async fn generate( repo: &R, store: &S, - target_format: ImageFormat, + target_format: InputProcessableFormat, source: Alias, process_path: PathBuf, process_args: Vec, @@ -148,7 +144,7 @@ async fn generate( source, process_path, process_args, - original_details.to_input_format(), + original_details.input_format(), None, hash, ) diff --git a/src/validate.rs b/src/validate.rs index 922ba5d..af347fe 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,9 +1,11 @@ +mod exiftool; +mod ffmpeg; +mod magick; + use crate::{ - config::{ImageFormat, MediaConfiguration}, either::Either, - error::{Error, UploadError}, - ffmpeg::{FileFormat, TranscodeOptions}, - magick::ValidInputType, + error::Error, + formats::{AnimationOutput, ImageOutput, InputFile, InternalFormat, PrescribedFormats}, }; use actix_web::web::Bytes; use tokio::io::AsyncRead; @@ -38,71 +40,56 @@ impl AsyncRead for UnvalidatedBytes { #[tracing::instrument(skip_all)] pub(crate) async fn validate_bytes( bytes: Bytes, - media: &MediaConfiguration, - validate: bool, -) -> Result<(ValidInputType, impl AsyncRead + Unpin), Error> { - let (details, input_type) = - if let Some(tup) = crate::ffmpeg::input_type_bytes(bytes.clone()).await? { - tup - } else { - crate::magick::input_type_bytes(bytes.clone()).await? - }; + prescribed: &PrescribedFormats, +) -> Result<(InternalFormat, impl AsyncRead + Unpin), Error> { + let discovery = crate::discover::discover_bytes(bytes.clone()).await?; - if !validate { - return Ok((input_type, Either::left(UnvalidatedBytes::new(bytes)))); - } + match &discovery.input { + InputFile::Image(input) => { + let ImageOutput { + format, + needs_transcode, + } = input.build_output(prescribed.image); - match (input_type.to_file_format(), media.format) { - (FileFormat::Video(video_format), _) => { - if !(media.enable_silent_video || media.enable_full_video) { - return Err(UploadError::SilentVideoDisabled.into()); - } - let transcode_options = TranscodeOptions::new(media, &details, video_format); - - if transcode_options.needs_reencode() { - Ok(( - transcode_options.output_type(), - Either::right(Either::left(Either::left( - crate::ffmpeg::transcode_bytes(bytes, transcode_options).await?, - ))), - )) + let read = if needs_transcode { + Either::left(Either::left(magick::convert_image( + input.format, + format, + bytes, + )?)) } else { - Ok(( - transcode_options.output_type(), - Either::right(Either::right(crate::exiftool::clear_metadata_bytes_read( - bytes, - )?)), - )) - } + Either::left(Either::right(exiftool::clear_metadata_bytes_read(bytes)?)) + }; + + Ok((InternalFormat::Image(format), read)) } - (FileFormat::Image(image_format), Some(format)) if image_format != format => Ok(( - ValidInputType::from_format(format), - Either::right(Either::left(Either::right( - crate::magick::convert_bytes_read(bytes, format)?, - ))), - )), - (FileFormat::Image(ImageFormat::Webp), _) => Ok(( - ValidInputType::Webp, - Either::right(Either::left(Either::right( - crate::magick::convert_bytes_read(bytes, ImageFormat::Webp)?, - ))), - )), - (FileFormat::Image(image_format), _) => { - if crate::exiftool::needs_reorienting(bytes.clone()).await? { - Ok(( - ValidInputType::from_format(image_format), - Either::right(Either::left(Either::right( - crate::magick::convert_bytes_read(bytes, image_format)?, - ))), - )) + InputFile::Animation(input) => { + let AnimationOutput { + format, + needs_transcode, + } = input.build_output(prescribed.animation); + + let read = if needs_transcode { + Either::right(Either::left(magick::convert_animation( + input.format, + format, + bytes, + )?)) } else { - Ok(( - ValidInputType::from_format(image_format), - Either::right(Either::right(crate::exiftool::clear_metadata_bytes_read( - bytes, - )?)), - )) - } + Either::right(Either::right(Either::left( + exiftool::clear_metadata_bytes_read(bytes)?, + ))) + }; + + Ok((InternalFormat::Animation(format), read)) + } + InputFile::Video(input) => { + let output = input.build_output(prescribed.video, prescribed.allow_audio); + let read = Either::right(Either::right(Either::right( + ffmpeg::transcode_bytes(*input, output, bytes).await?, + ))); + + Ok((InternalFormat::Video(output.internal_format()), read)) } } } diff --git a/src/validate/exiftool.rs b/src/validate/exiftool.rs new file mode 100644 index 0000000..9fb4416 --- /dev/null +++ b/src/validate/exiftool.rs @@ -0,0 +1,12 @@ +use actix_web::web::Bytes; +use tokio::io::AsyncRead; + +use crate::{exiftool::ExifError, process::Process}; + +#[tracing::instrument(level = "trace", skip(input))] +pub(crate) fn clear_metadata_bytes_read(input: Bytes) -> Result { + let process = + Process::run("exiftool", &["-all=", "-", "-out", "-"]).map_err(ExifError::Process)?; + + Ok(process.bytes_read(input)) +} diff --git a/src/validate/ffmpeg.rs b/src/validate/ffmpeg.rs new file mode 100644 index 0000000..bbb4c14 --- /dev/null +++ b/src/validate/ffmpeg.rs @@ -0,0 +1,112 @@ +use actix_web::web::Bytes; +use tokio::io::AsyncRead; + +use crate::{ + ffmpeg::FfMpegError, + formats::{OutputVideoFormat, VideoFormat}, + process::Process, +}; + +pub(super) async fn transcode_bytes( + input_format: VideoFormat, + output_format: OutputVideoFormat, + bytes: Bytes, +) -> Result { + let input_file = crate::tmp_file::tmp_file(None); + let input_file_str = input_file.to_str().ok_or(FfMpegError::Path)?; + crate::store::file_store::safe_create_parent(&input_file) + .await + .map_err(FfMpegError::CreateDir)?; + + let mut tmp_one = crate::file::File::create(&input_file) + .await + .map_err(FfMpegError::CreateFile)?; + tmp_one + .write_from_bytes(bytes) + .await + .map_err(FfMpegError::Write)?; + tmp_one.close().await.map_err(FfMpegError::CloseFile)?; + + let output_file = crate::tmp_file::tmp_file(None); + let output_file_str = output_file.to_str().ok_or(FfMpegError::Path)?; + + transcode_files(input_file_str, input_format, output_file_str, output_format).await?; + + let tmp_two = crate::file::File::open(&output_file) + .await + .map_err(FfMpegError::OpenFile)?; + let stream = tmp_two + .read_to_stream(None, None) + .await + .map_err(FfMpegError::ReadFile)?; + let reader = tokio_util::io::StreamReader::new(stream); + let clean_reader = crate::tmp_file::cleanup_tmpfile(reader, output_file); + + Ok(Box::pin(clean_reader)) +} + +async fn transcode_files( + input_path: &str, + input_format: VideoFormat, + output_path: &str, + output_format: OutputVideoFormat, +) -> Result<(), FfMpegError> { + if let Some(audio_codec) = output_format.ffmpeg_audio_codec() { + Process::run( + "ffmpeg", + &[ + "-hide_banner", + "-v", + "warning", + "-f", + input_format.ffmpeg_format(), + "-i", + input_path, + "-pix_fmt", + output_format.pix_fmt(), + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:a", + audio_codec, + "-c:v", + output_format.ffmpeg_video_codec(), + "-f", + output_format.ffmpeg_format(), + output_path, + ], + ) + .map_err(FfMpegError::Process)? + .wait() + .await + .map_err(FfMpegError::Process)?; + } else { + Process::run( + "ffmpeg", + &[ + "-hide_banner", + "-v", + "warning", + "-f", + input_format.ffmpeg_format(), + "-i", + input_path, + "-pix_fmt", + output_format.pix_fmt(), + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-an", + "-c:v", + output_format.ffmpeg_video_codec(), + "-f", + output_format.ffmpeg_format(), + output_path, + ], + ) + .map_err(FfMpegError::Process)? + .wait() + .await + .map_err(FfMpegError::Process)?; + } + + Ok(()) +} diff --git a/src/validate/magick.rs b/src/validate/magick.rs new file mode 100644 index 0000000..d6a7bab --- /dev/null +++ b/src/validate/magick.rs @@ -0,0 +1,39 @@ +use actix_web::web::Bytes; +use tokio::io::AsyncRead; + +use crate::{ + formats::{AnimationFormat, ImageFormat}, + magick::MagickError, + process::Process, +}; + +pub(super) fn convert_image( + input: ImageFormat, + output: ImageFormat, + bytes: Bytes, +) -> Result { + let input_arg = format!("{}:-", input.magick_format()); + let output_arg = format!("{}:-", output.magick_format()); + + let process = Process::run( + "magick", + &["-strip", "-auto-orient", &input_arg, &output_arg], + ) + .map_err(MagickError::Process)?; + + Ok(process.bytes_read(bytes)) +} + +pub(super) fn convert_animation( + input: AnimationFormat, + output: AnimationFormat, + bytes: Bytes, +) -> Result { + let input_arg = format!("{}:-", input.magick_format()); + let output_arg = format!("{}:-", output.magick_format()); + + let process = Process::run("magick", &["-strip", &input_arg, "-coalesce", &output_arg]) + .map_err(MagickError::Process)?; + + Ok(process.bytes_read(bytes)) +}