diff --git a/src/config.rs b/src/config.rs index 2c4279e..506575e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,10 +12,9 @@ use defaults::Defaults; pub(crate) use commandline::Operation; pub(crate) use file::{ - ConfigFile as Configuration, Media as MediaConfiguration, ObjectStorage, OpenTelemetry, Repo, - Sled, Store, Tracing, + ConfigFile as Configuration, ObjectStorage, OpenTelemetry, Repo, Sled, Store, Tracing, }; -pub(crate) use primitives::{AudioCodec, Filesystem, ImageFormat, LogFormat, VideoCodec}; +pub(crate) use primitives::{Filesystem, LogFormat}; /// Source for pict-rs configuration when embedding as a library pub enum ConfigSource { diff --git a/src/config/commandline.rs b/src/config/commandline.rs index 7d50ec0..a16537d 100644 --- a/src/config/commandline.rs +++ b/src/config/commandline.rs @@ -1,5 +1,6 @@ use crate::{ - config::primitives::{AudioCodec, ImageFormat, LogFormat, Targets, VideoCodec}, + config::primitives::{AudioCodec, LogFormat, Targets, VideoCodec}, + formats::ImageFormat, serde_str::Serde, }; use clap::{Parser, Subcommand}; diff --git a/src/config/file.rs b/src/config/file.rs index 390d3ca..a93f72d 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -1,5 +1,6 @@ use crate::{ - config::primitives::{AudioCodec, Filesystem, ImageFormat, LogFormat, Targets, VideoCodec}, + config::primitives::{AudioCodec, Filesystem, LogFormat, Targets, VideoCodec}, + formats::ImageFormat, serde_str::Serde, }; use once_cell::sync::OnceCell; diff --git a/src/config/primitives.rs b/src/config/primitives.rs index 0bb3e4c..172c6d3 100644 --- a/src/config/primitives.rs +++ b/src/config/primitives.rs @@ -1,4 +1,3 @@ -use crate::magick::ValidInputType; use clap::ValueEnum; use std::{fmt::Display, path::PathBuf, str::FromStr}; use tracing::Level; @@ -25,28 +24,6 @@ pub(crate) enum LogFormat { Pretty, } -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - serde::Deserialize, - serde::Serialize, - ValueEnum, -)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ImageFormat { - Avif, - Jpeg, - Jxl, - Png, - Webp, -} - #[derive( Clone, Copy, @@ -169,32 +146,6 @@ pub(crate) enum Store { ObjectStorage(ObjectStorage), } -impl ImageFormat { - pub(crate) fn as_hint(self) -> ValidInputType { - ValidInputType::from_format(self) - } - - pub(crate) fn as_magick_format(self) -> &'static str { - match self { - Self::Avif => "AVIF", - Self::Jpeg => "JPEG", - Self::Jxl => "JXL", - Self::Png => "PNG", - Self::Webp => "WEBP", - } - } - - pub(crate) fn as_ext(self) -> &'static str { - match self { - Self::Avif => ".avif", - Self::Jpeg => ".jpeg", - Self::Jxl => ".jxl", - Self::Png => ".png", - Self::Webp => ".webp", - } - } -} - impl From for Store { fn from(f: Filesystem) -> Self { Self::Filesystem(f) @@ -260,21 +211,6 @@ impl Display for Targets { } } -impl FromStr for ImageFormat { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "avif" => Ok(Self::Avif), - "jpeg" | "jpg" => Ok(Self::Jpeg), - "jxl" => Ok(Self::Jxl), - "png" => Ok(Self::Png), - "webp" => Ok(Self::Webp), - other => Err(format!("Invalid variant: {other}")), - } - } -} - impl FromStr for LogFormat { type Err = String; @@ -288,15 +224,6 @@ impl FromStr for LogFormat { } } -impl Display for ImageFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.to_possible_value() - .expect("no values are skipped") - .get_name() - .fmt(f) - } -} - impl Display for LogFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.to_possible_value() diff --git a/src/details.rs b/src/details.rs index 1e760d6..e40c0ae 100644 --- a/src/details.rs +++ b/src/details.rs @@ -1,7 +1,7 @@ use crate::{ + discover::DiscoveryLite, error::Error, formats::{InternalFormat, InternalVideoFormat}, - magick::{video_mp4, video_webm, ValidInputType}, serde_str::Serde, store::Store, }; @@ -17,9 +17,9 @@ pub(crate) enum MaybeHumanDate { #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] pub(crate) struct Details { - width: usize, - height: usize, - frames: Option, + width: u16, + height: u16, + frames: Option, content_type: Serde, created_at: MaybeHumanDate, #[serde(skip_serializing_if = "Option::is_none")] @@ -27,71 +27,41 @@ pub(crate) struct Details { } impl Details { - pub(crate) fn is_motion(&self) -> bool { + pub(crate) fn is_video(&self) -> bool { self.content_type.type_() == "video" - || self.content_type.type_() == "image" && self.content_type.subtype() == "gif" } - pub(crate) async fn from_bytes(input: web::Bytes, hint: ValidInputType) -> Result { - let details = if hint.is_video() { - crate::ffmpeg::details_bytes(input.clone()).await? - } else { - None - }; - - let details = if let Some(details) = details { - details - } else { - crate::magick::details_bytes(input, Some(hint)).await? - }; - - Ok(Details::now( - details.width, - details.height, - details.mime_type, - details.frames, - )) - } - - pub(crate) async fn from_store( - store: S, - identifier: S::Identifier, - expected_format: Option, - ) -> Result { - let details = if expected_format.map(|t| t.is_video()).unwrap_or(true) { - crate::ffmpeg::details_store(&store, &identifier).await? - } else { - None - }; - - let details = if let Some(details) = details { - details - } else { - crate::magick::details_store(store, identifier, expected_format).await? - }; - - Ok(Details::now( - details.width, - details.height, - details.mime_type, - details.frames, - )) - } - - pub(crate) fn now( - width: usize, - height: usize, - content_type: mime::Mime, - frames: Option, - ) -> Self { - Details { + pub(crate) async fn from_bytes(input: web::Bytes) -> Result { + let DiscoveryLite { + format, width, height, frames, - content_type: Serde::new(content_type), - created_at: MaybeHumanDate::HumanDate(time::OffsetDateTime::now_utc()), - format: None, + } = crate::discover::discover_bytes_lite(input).await?; + + Ok(Details::from_parts(format, width, height, frames)) + } + + pub(crate) async fn from_store( + store: &S, + identifier: &S::Identifier, + ) -> Result { + let DiscoveryLite { + format, + width, + height, + frames, + } = crate::discover::discover_store_lite(store, identifier).await?; + + Ok(Details::from_parts(format, width, height, frames)) + } + + pub(crate) fn internal_format(&self) -> Option { + if let Some(format) = self.format { + return Some(format); } + + InternalFormat::maybe_from_media_type(&self.content_type, self.frames.is_some()) } pub(crate) fn content_type(&self) -> mime::Mime { @@ -102,12 +72,12 @@ impl Details { self.created_at.into() } - pub(crate) fn input_format(&self) -> Option { - if *self.content_type == video_mp4() { + pub(crate) fn video_format(&self) -> Option { + if *self.content_type == crate::formats::mimes::video_mp4() { return Some(InternalVideoFormat::Mp4); } - if *self.content_type == video_webm() { + if *self.content_type == crate::formats::mimes::video_webm() { return Some(InternalVideoFormat::Webm); } @@ -121,9 +91,9 @@ impl Details { frames: Option, ) -> Self { Self { - width: width.into(), - height: height.into(), - frames: frames.map(|f| f.try_into().expect("Reasonable size")), + width, + height, + frames, content_type: Serde::new(format.media_type()), created_at: MaybeHumanDate::HumanDate(OffsetDateTime::now_utc()), format: Some(format), diff --git a/src/discover.rs b/src/discover.rs index aac21f1..840d264 100644 --- a/src/discover.rs +++ b/src/discover.rs @@ -4,7 +4,10 @@ mod magick; use actix_web::web::Bytes; -use crate::formats::{AnimationInput, ImageInput, InputFile, InternalFormat, InternalVideoFormat}; +use crate::{ + formats::{InputFile, InternalFormat}, + store::Store, +}; pub(crate) struct Discovery { pub(crate) input: InputFile, @@ -13,20 +16,42 @@ pub(crate) struct Discovery { 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) struct DiscoveryLite { + pub(crate) format: InternalFormat, + pub(crate) width: u16, + pub(crate) height: u16, + pub(crate) frames: Option, +} + +pub(crate) async fn discover_bytes_lite( + bytes: Bytes, +) -> Result { + if let Some(discovery) = ffmpeg::discover_bytes_lite(bytes.clone()).await? { + return Ok(discovery); } + + let discovery = magick::discover_bytes_lite(bytes).await?; + + Ok(discovery) +} + +pub(crate) async fn discover_store_lite( + store: &S, + identifier: &S::Identifier, +) -> Result +where + S: Store + 'static, +{ + if let Some(discovery) = + ffmpeg::discover_stream_lite(store.to_stream(identifier, None, None).await?).await? + { + return Ok(discovery); + } + + let discovery = + magick::discover_stream_lite(store.to_stream(identifier, None, None).await?).await?; + + Ok(discovery) } pub(crate) async fn discover_bytes(bytes: Bytes) -> Result { diff --git a/src/discover/ffmpeg.rs b/src/discover/ffmpeg.rs index cb1799e..3ec4246 100644 --- a/src/discover/ffmpeg.rs +++ b/src/discover/ffmpeg.rs @@ -2,43 +2,25 @@ use std::{collections::HashSet, sync::OnceLock}; use crate::{ ffmpeg::FfMpegError, - formats::{AnimationFormat, AnimationInput, ImageFormat, ImageInput, InputFile, VideoFormat}, + formats::{ + AnimationFormat, AnimationInput, ImageFormat, ImageInput, InputFile, InternalFormat, + InternalVideoFormat, VideoFormat, + }, process::Process, }; use actix_web::web::Bytes; +use futures_util::Stream; use tokio::io::AsyncReadExt; -use super::Discovery; +use super::{Discovery, DiscoveryLite}; -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, - }), - ), +const FFMPEG_FORMAT_MAPPINGS: &[(&str, InternalFormat)] = &[ + ("apng", InternalFormat::Animation(AnimationFormat::Apng)), + ("gif", InternalFormat::Animation(AnimationFormat::Gif)), + ("mp4", InternalFormat::Video(InternalVideoFormat::Mp4)), + ("png_pipe", InternalFormat::Image(ImageFormat::Png)), + ("webm", InternalFormat::Video(InternalVideoFormat::Webm)), + ("webp_pipe", InternalFormat::Image(ImageFormat::Webp)), ]; #[derive(Debug, serde::Deserialize)] @@ -76,7 +58,23 @@ struct Flags { } pub(super) async fn discover_bytes(bytes: Bytes) -> Result, FfMpegError> { - discover_file(move |mut file| async move { + discover_file_full(move |mut file| { + let bytes = bytes.clone(); + + async move { + file.write_from_bytes(bytes) + .await + .map_err(FfMpegError::Write)?; + Ok(file) + } + }) + .await +} + +pub(super) async fn discover_bytes_lite( + bytes: Bytes, +) -> Result, FfMpegError> { + discover_file_lite(move |mut file| async move { file.write_from_bytes(bytes) .await .map_err(FfMpegError::Write)?; @@ -85,8 +83,105 @@ pub(super) async fn discover_bytes(bytes: Bytes) -> Result, Ff .await } +pub(super) async fn discover_stream_lite(stream: S) -> Result, FfMpegError> +where + S: Stream> + Unpin, +{ + discover_file_lite(move |mut file| async move { + file.write_from_stream(stream) + .await + .map_err(FfMpegError::Write)?; + Ok(file) + }) + .await +} + +async fn discover_file_lite(f: F) -> Result, FfMpegError> +where + F: FnOnce(crate::file::File) -> Fut, + Fut: std::future::Future>, +{ + let Some(DiscoveryLite { + format, + width, + height, + frames, + }) = discover_file(f) + .await? else { + return Ok(None); + }; + + // If we're not confident in our discovery don't return it + if width == 0 || height == 0 { + return Ok(None); + } + + Ok(Some(DiscoveryLite { + format, + width, + height, + frames, + })) +} + +async fn discover_file_full(f: F) -> Result, FfMpegError> +where + F: Fn(crate::file::File) -> Fut + Clone, + Fut: std::future::Future>, +{ + let Some(DiscoveryLite { format, width, height, frames }) = discover_file(f.clone()).await? else { + return Ok(None); + }; + + match format { + InternalFormat::Video(InternalVideoFormat::Webm) => { + static ALPHA_PIXEL_FORMATS: OnceLock> = OnceLock::new(); + + let format = pixel_format(f).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, + })) + } + InternalFormat::Video(InternalVideoFormat::Mp4) => Ok(Some(Discovery { + input: InputFile::Video(VideoFormat::Mp4), + width, + height, + frames, + })), + InternalFormat::Animation(format) => Ok(Some(Discovery { + input: InputFile::Animation(AnimationInput { format }), + width, + height, + frames, + })), + InternalFormat::Image(format) => Ok(Some(Discovery { + input: InputFile::Image(ImageInput { + format, + needs_reorient: false, + }), + width, + height, + frames, + })), + } +} + #[tracing::instrument(skip(f))] -async fn discover_file(f: F) -> Result, FfMpegError> +async fn discover_file(f: F) -> Result, FfMpegError> where F: FnOnce(crate::file::File) -> Fut, Fut: std::future::Future>, @@ -134,43 +229,26 @@ where 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)), - } + parse_discovery_ffmpeg(output) } -async fn pixel_format(input_file: &str) -> Result { +async fn pixel_format(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(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", &[ @@ -182,7 +260,7 @@ async fn pixel_format(input_file: &str) -> Result { "stream=pix_fmt", "-of", "compact=p=0:nk=1", - input_file, + input_file_str, ], ) .map_err(FfMpegError::Process)?; @@ -193,6 +271,11 @@ async fn pixel_format(input_file: &str) -> Result { .read_to_end(&mut output) .await .map_err(FfMpegError::Read)?; + + tokio::fs::remove_file(input_file_str) + .await + .map_err(FfMpegError::RemoveFile)?; + Ok(String::from_utf8_lossy(&output).trim().to_string()) } @@ -238,7 +321,9 @@ fn parse_pixel_formats(formats: PixelFormatOutput) -> HashSet { .collect() } -fn parse_discovery_ffmpeg(discovery: FfMpegDiscovery) -> Result, FfMpegError> { +fn parse_discovery_ffmpeg( + discovery: FfMpegDiscovery, +) -> Result, FfMpegError> { let FfMpegDiscovery { streams: [FfMpegStream { @@ -259,10 +344,8 @@ fn parse_discovery_ffmpeg(discovery: FfMpegDiscovery) -> Result Result Result { + discover_file_lite(move |mut file| async move { + file.write_from_bytes(bytes) + .await + .map_err(MagickError::Write)?; + Ok(file) + }) + .await +} + +pub(super) async fn discover_stream_lite(stream: S) -> Result +where + S: Stream> + Unpin + 'static, +{ + discover_file_lite(move |mut file| async move { + file.write_from_stream(stream) + .await + .map_err(MagickError::Write)?; + Ok(file) + }) + .await +} + pub(super) async fn confirm_bytes( discovery: Option, bytes: Bytes, @@ -125,6 +149,26 @@ where Ok(lines) } +async fn discover_file_lite(f: F) -> Result +where + F: FnOnce(crate::file::File) -> Fut, + Fut: std::future::Future>, +{ + let Discovery { + input, + width, + height, + frames, + } = discover_file(f).await?; + + Ok(DiscoveryLite { + format: input.internal_format(), + width, + height, + frames, + }) +} + async fn discover_file(f: F) -> Result where F: FnOnce(crate::file::File) -> Fut, diff --git a/src/error.rs b/src/error.rs index 7eb50d7..c887b3c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -99,9 +99,6 @@ pub(crate) enum UploadError { #[error("Process endpoint was called with invalid extension")] UnsupportedProcessExtension, - #[error("Gif uploads are not enabled")] - SilentVideoDisabled, - #[error("Unable to download image, bad response {0}")] Download(actix_web::http::StatusCode), @@ -169,14 +166,12 @@ impl ResponseError for Error { crate::repo::RepoError::AlreadyClaimed, )) | UploadError::Repo(crate::repo::RepoError::AlreadyClaimed) - | UploadError::UnsupportedProcessExtension - | UploadError::SilentVideoDisabled, + | UploadError::UnsupportedProcessExtension, ) => StatusCode::BAD_REQUEST, Some(UploadError::Magick(e)) if e.is_client_error() => StatusCode::BAD_REQUEST, Some(UploadError::Ffmpeg(e)) if e.is_client_error() => StatusCode::BAD_REQUEST, Some(UploadError::Exiftool(e)) if e.is_client_error() => StatusCode::BAD_REQUEST, Some(UploadError::MissingAlias) => StatusCode::NOT_FOUND, - Some(UploadError::Magick(e)) if e.is_not_found() => StatusCode::NOT_FOUND, Some(UploadError::Ffmpeg(e)) if e.is_not_found() => StatusCode::NOT_FOUND, Some(UploadError::InvalidToken) => StatusCode::FORBIDDEN, Some(UploadError::Range) => StatusCode::RANGE_NOT_SATISFIABLE, diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index 1403447..d08c052 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -2,44 +2,11 @@ mod tests; use crate::{ - config::{AudioCodec, ImageFormat, MediaConfiguration, VideoCodec}, formats::InternalVideoFormat, - magick::{Details, ParseDetailsError, ValidInputType, ValidateDetailsError}, process::{Process, ProcessError}, store::{Store, StoreError}, }; -use actix_web::web::Bytes; -use once_cell::sync::OnceCell; -use std::collections::HashSet; -use tokio::io::{AsyncRead, AsyncReadExt}; - -#[derive(Debug)] -pub(crate) struct TranscodeOptions { - input_format: VideoFormat, - output: TranscodeOutputOptions, -} - -#[derive(Debug)] -enum TranscodeOutputOptions { - Gif, - Video { - video_codec: VideoCodec, - audio_codec: Option, - }, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) enum VideoFormat { - Gif, - Mp4, - Webm, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) enum OutputFormat { - Mp4, - Webm, -} +use tokio::io::AsyncRead; #[derive(Clone, Copy, Debug)] pub(crate) enum ThumbnailFormat { @@ -47,28 +14,6 @@ pub(crate) enum ThumbnailFormat { // Webp, } -#[derive(Clone, Copy, Debug)] -pub(crate) enum FileFormat { - Image(ImageFormat), - Video(VideoFormat), -} - -#[derive(serde::Deserialize)] -struct PixelFormatOutput { - pixel_formats: Vec, -} - -#[derive(serde::Deserialize)] -struct PixelFormat { - name: String, - flags: Flags, -} - -#[derive(serde::Deserialize)] -struct Flags { - alpha: usize, -} - #[derive(Debug, thiserror::Error)] pub(crate) enum FfMpegError { #[error("Error in ffmpeg process")] @@ -101,12 +46,6 @@ pub(crate) enum FfMpegError { #[error("Error removing file")] RemoveFile(#[source] std::io::Error), - #[error("Error parsing details")] - Details(#[source] ParseDetailsError), - - #[error("Media details are invalid")] - ValidateDetails(#[source] ValidateDetailsError), - #[error("Error in store")] Store(#[source] StoreError), @@ -117,8 +56,7 @@ pub(crate) enum FfMpegError { impl FfMpegError { pub(crate) fn is_client_error(&self) -> bool { // Failing validation or ffmpeg bailing probably means bad input - matches!(self, Self::ValidateDetails(_)) - || matches!(self, Self::Process(ProcessError::Status(_))) + matches!(self, Self::Process(ProcessError::Status(_))) } pub(crate) fn is_not_found(&self) -> bool { @@ -130,205 +68,6 @@ impl FfMpegError { } } -impl TranscodeOptions { - pub(crate) fn new( - media: &MediaConfiguration, - details: &Details, - input_format: VideoFormat, - ) -> Self { - if let VideoFormat::Gif = input_format { - if details.width <= media.gif.max_width - && details.height <= media.gif.max_height - && details.width * details.height <= media.gif.max_area - && details.frames.unwrap_or(1) <= media.gif.max_frame_count - { - return Self { - input_format, - output: TranscodeOutputOptions::gif(), - }; - } - } - - Self { - input_format, - output: TranscodeOutputOptions::video(media), - } - } - - pub(crate) const fn needs_reencode(&self) -> bool { - !matches!( - (self.input_format, &self.output), - (VideoFormat::Gif, TranscodeOutputOptions::Gif) - ) - } - - const fn input_file_extension(&self) -> &'static str { - self.input_format.to_file_extension() - } - - const fn output_ffmpeg_format(&self) -> &'static str { - match self.output { - TranscodeOutputOptions::Gif => "gif", - TranscodeOutputOptions::Video { video_codec, .. } => { - video_codec.to_output_format().to_ffmpeg_format() - } - } - } - - const fn output_file_extension(&self) -> &'static str { - match self.output { - TranscodeOutputOptions::Gif => ".gif", - TranscodeOutputOptions::Video { video_codec, .. } => { - video_codec.to_output_format().to_file_extension() - } - } - } - - const fn supports_alpha(&self) -> bool { - matches!( - self.output, - TranscodeOutputOptions::Gif - | TranscodeOutputOptions::Video { - video_codec: VideoCodec::Vp8 | VideoCodec::Vp9, - .. - } - ) - } - - fn execute( - &self, - input_path: &str, - output_path: &str, - alpha: bool, - ) -> Result { - match self.output { - TranscodeOutputOptions::Gif => Process::run("ffmpeg", &[ - "-hide_banner", - "-v", - "warning", - "-i", - input_path, - "-filter_complex", - "[0:v] split [a][b]; [a] palettegen=stats_mode=single [p]; [b][p] paletteuse=new=1", - "-an", - "-f", - self.output_ffmpeg_format(), - output_path - ]), - TranscodeOutputOptions::Video { - video_codec, - audio_codec: None, - } => Process::run( - "ffmpeg", - &[ - "-hide_banner", - "-v", - "warning", - "-i", - input_path, - "-pix_fmt", - video_codec.pix_fmt(alpha), - "-vf", - "scale=trunc(iw/2)*2:trunc(ih/2)*2", - "-an", - "-c:v", - video_codec.to_ffmpeg_codec(), - "-f", - self.output_ffmpeg_format(), - output_path, - ], - ), - TranscodeOutputOptions::Video { - video_codec, - audio_codec: Some(audio_codec), - } => Process::run( - "ffmpeg", - &[ - "-hide_banner", - "-v", - "warning", - "-i", - input_path, - "-pix_fmt", - video_codec.pix_fmt(alpha), - "-vf", - "scale=trunc(iw/2)*2:trunc(ih/2)*2", - "-c:a", - audio_codec.to_ffmpeg_codec(), - "-c:v", - video_codec.to_ffmpeg_codec(), - "-f", - self.output_ffmpeg_format(), - output_path, - ], - ), - } - } - - pub(crate) const fn output_type(&self) -> ValidInputType { - match self.output { - TranscodeOutputOptions::Gif => ValidInputType::Gif, - TranscodeOutputOptions::Video { video_codec, .. } => { - ValidInputType::from_video_codec(video_codec) - } - } - } -} - -impl TranscodeOutputOptions { - fn video(media: &MediaConfiguration) -> Self { - Self::Video { - video_codec: media.video_codec, - audio_codec: if media.enable_full_video { - Some( - media - .audio_codec - .unwrap_or(media.video_codec.to_output_format().default_audio_codec()), - ) - } else { - None - }, - } - } - - const fn gif() -> Self { - Self::Gif - } -} - -impl ValidInputType { - pub(crate) fn to_file_format(self) -> FileFormat { - match self { - Self::Gif => FileFormat::Video(VideoFormat::Gif), - Self::Mp4 => FileFormat::Video(VideoFormat::Mp4), - Self::Webm => FileFormat::Video(VideoFormat::Webm), - Self::Avif => FileFormat::Image(ImageFormat::Avif), - Self::Jpeg => FileFormat::Image(ImageFormat::Jpeg), - Self::Jxl => FileFormat::Image(ImageFormat::Jxl), - Self::Png => FileFormat::Image(ImageFormat::Png), - Self::Webp => FileFormat::Image(ImageFormat::Webp), - } - } -} - -impl VideoFormat { - const fn to_file_extension(self) -> &'static str { - match self { - Self::Gif => ".gif", - Self::Mp4 => ".mp4", - Self::Webm => ".webm", - } - } - - fn to_mime(self) -> mime::Mime { - match self { - Self::Gif => mime::IMAGE_GIF, - Self::Mp4 => crate::magick::video_mp4(), - Self::Webm => crate::magick::video_webm(), - } - } -} - impl ThumbnailFormat { const fn as_ffmpeg_codec(self) -> &'static str { match self { @@ -352,367 +91,6 @@ impl ThumbnailFormat { } } -impl OutputFormat { - const fn to_ffmpeg_format(self) -> &'static str { - match self { - Self::Mp4 => "mp4", - Self::Webm => "webm", - } - } - - const fn default_audio_codec(self) -> AudioCodec { - match self { - Self::Mp4 => AudioCodec::Aac, - Self::Webm => AudioCodec::Opus, - } - } - - const fn to_file_extension(self) -> &'static str { - match self { - Self::Mp4 => ".mp4", - Self::Webm => ".webm", - } - } -} - -impl VideoCodec { - const fn to_output_format(self) -> OutputFormat { - match self { - Self::H264 | Self::H265 => OutputFormat::Mp4, - Self::Av1 | Self::Vp8 | Self::Vp9 => OutputFormat::Webm, - } - } - - const fn to_ffmpeg_codec(self) -> &'static str { - match self { - Self::Av1 => "av1", - Self::H264 => "h264", - Self::H265 => "hevc", - Self::Vp8 => "vp8", - Self::Vp9 => "vp9", - } - } - - const fn pix_fmt(&self, alpha: bool) -> &'static str { - match (self, alpha) { - (VideoCodec::Vp8 | VideoCodec::Vp9, true) => "yuva420p", - _ => "yuv420p", - } - } -} - -impl AudioCodec { - const fn to_ffmpeg_codec(self) -> &'static str { - match self { - Self::Aac => "aac", - Self::Opus => "libopus", - Self::Vorbis => "vorbis", - } - } -} - -const FORMAT_MAPPINGS: &[(&str, VideoFormat)] = &[ - ("gif", VideoFormat::Gif), - ("mp4", VideoFormat::Mp4), - ("webm", VideoFormat::Webm), -]; - -pub(crate) async fn input_type_bytes( - input: Bytes, -) -> Result, FfMpegError> { - if let Some(details) = details_bytes(input).await? { - let input_type = details - .validate_input() - .map_err(FfMpegError::ValidateDetails)?; - - return Ok(Some((details, input_type))); - } - - Ok(None) -} - -pub(crate) async fn details_store( - store: &S, - identifier: &S::Identifier, -) -> Result, FfMpegError> { - details_file(move |mut tmp_one| async move { - let stream = store - .to_stream(identifier, None, None) - .await - .map_err(FfMpegError::Store)?; - tmp_one - .write_from_stream(stream) - .await - .map_err(FfMpegError::Write)?; - Ok(tmp_one) - }) - .await -} - -pub(crate) async fn details_bytes(input: Bytes) -> Result, FfMpegError> { - details_file(move |mut tmp_one| async move { - tmp_one - .write_from_bytes(input) - .await - .map_err(FfMpegError::Write)?; - Ok(tmp_one) - }) - .await -} - -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() -} - -#[derive(Debug, serde::Deserialize)] -struct DetailsOutput { - streams: [Stream; 1], - format: Format, -} - -#[derive(Debug, serde::Deserialize)] -struct Stream { - width: usize, - height: usize, - nb_read_frames: Option, -} - -#[derive(Debug, serde::Deserialize)] -struct Format { - format_name: String, -} - -#[tracing::instrument(skip(f))] -async fn details_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: DetailsOutput = serde_json::from_slice(&output).map_err(FfMpegError::Json)?; - - parse_details(output) -} - -fn parse_details(output: DetailsOutput) -> Result, FfMpegError> { - tracing::debug!("OUTPUT: {:?}", output); - - let [stream] = output.streams; - let Format { format_name } = output.format; - - for (k, v) in FORMAT_MAPPINGS { - if format_name.contains(k) { - return parse_details_inner( - stream.width, - stream.height, - stream.nb_read_frames.as_deref(), - *v, - ) - .map_err(FfMpegError::Details); - } - } - - Ok(None) -} - -fn parse_details_inner( - width: usize, - height: usize, - frames: Option<&str>, - format: VideoFormat, -) -> Result, ParseDetailsError> { - let frames = frames - .map(|frames| { - frames - .parse() - .map_err(|_| ParseDetailsError::ParseFrames(String::from(frames))) - }) - .transpose()? - .unwrap_or(1); - - // Probably a still image. ffmpeg thinks AVIF is an mp4 - if frames == 1 { - return Ok(None); - } - - Ok(Some(Details { - mime_type: format.to_mime(), - width, - height, - frames: Some(frames), - })) -} - -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()) -} - -#[tracing::instrument(skip(input))] -pub(crate) async fn transcode_bytes( - input: Bytes, - transcode_options: TranscodeOptions, -) -> Result { - let input_file = crate::tmp_file::tmp_file(Some(transcode_options.input_file_extension())); - 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 output_file = crate::tmp_file::tmp_file(Some(transcode_options.output_file_extension())); - let output_file_str = output_file.to_str().ok_or(FfMpegError::Path)?; - crate::store::file_store::safe_create_parent(&output_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(input) - .await - .map_err(FfMpegError::Write)?; - tmp_one.close().await.map_err(FfMpegError::CloseFile)?; - - let alpha = if transcode_options.supports_alpha() { - static ALPHA_PIXEL_FORMATS: OnceCell> = OnceCell::new(); - - let format = pixel_format(input_file_str).await?; - - 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 - } - } - } else { - false - }; - - let process = transcode_options - .execute(input_file_str, output_file_str, alpha) - .map_err(FfMpegError::Process)?; - - process.wait().await.map_err(FfMpegError::Process)?; - tokio::fs::remove_file(input_file) - .await - .map_err(FfMpegError::RemoveFile)?; - - 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)) -} - #[tracing::instrument(skip(store))] pub(crate) async fn thumbnail( store: S, diff --git a/src/formats.rs b/src/formats.rs index 7c69cd7..1b0cfd8 100644 --- a/src/formats.rs +++ b/src/formats.rs @@ -24,13 +24,6 @@ pub(crate) enum InputFile { Video(VideoFormat), } -#[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), @@ -65,31 +58,6 @@ pub(crate) enum InputProcessableFormat { } impl InputFile { - const fn file_extension(&self) -> &'static str { - match self { - 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(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, allow_audio, .. - }, - ) => OutputFile::Video(input.build_output(*video, *allow_audio)), - } - } - pub(crate) const fn internal_format(&self) -> InternalFormat { match self { Self::Image(ImageInput { format, .. }) => InternalFormat::Image(*format), @@ -99,24 +67,6 @@ impl InputFile { } } -impl OutputFile { - const fn file_extension(&self) -> &'static str { - match self { - Self::Image(ImageOutput { format, .. }) => format.file_extension(), - Self::Animation(AnimationOutput { format, .. }) => format.file_extension(), - Self::Video(format) => format.file_extension(), - } - } - - pub(crate) const fn internal_format(&self) -> InternalFormat { - match self { - 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 { @@ -126,6 +76,23 @@ impl InternalFormat { } } + pub(crate) fn maybe_from_media_type(mime: &mime::Mime, has_frames: bool) -> Option { + match (mime.type_(), mime.subtype().as_str(), has_frames) { + (mime::IMAGE, "apng", _) => Some(Self::Animation(AnimationFormat::Apng)), + (mime::IMAGE, "avif", true) => Some(Self::Animation(AnimationFormat::Avif)), + (mime::IMAGE, "avif", false) => Some(Self::Image(ImageFormat::Avif)), + (mime::IMAGE, "gif", _) => Some(Self::Animation(AnimationFormat::Gif)), + (mime::IMAGE, "jpeg", _) => Some(Self::Image(ImageFormat::Jpeg)), + (mime::IMAGE, "jxl", _) => Some(Self::Image(ImageFormat::Jxl)), + (mime::IMAGE, "png", _) => Some(Self::Image(ImageFormat::Png)), + (mime::IMAGE, "webp", true) => Some(Self::Animation(AnimationFormat::Webp)), + (mime::IMAGE, "webp", false) => Some(Self::Image(ImageFormat::Webp)), + (mime::VIDEO, "mp4", _) => Some(Self::Video(InternalVideoFormat::Mp4)), + (mime::VIDEO, "webm", _) => Some(Self::Video(InternalVideoFormat::Webm)), + _ => None, + } + } + pub(crate) const fn file_extension(self) -> &'static str { match self { Self::Image(format) => format.file_extension(), @@ -161,6 +128,33 @@ impl ProcessableFormat { Self::Animation(format) => format.magick_format(), } } + + pub(crate) fn process_to(self, output: InputProcessableFormat) -> Option { + match (self, output) { + (Self::Image(_), InputProcessableFormat::Avif) => Some(Self::Image(ImageFormat::Avif)), + (Self::Image(_), InputProcessableFormat::Jpeg) => Some(Self::Image(ImageFormat::Jpeg)), + (Self::Image(_), InputProcessableFormat::Jxl) => Some(Self::Image(ImageFormat::Jxl)), + (Self::Image(_), InputProcessableFormat::Png) => Some(Self::Image(ImageFormat::Png)), + (Self::Image(_), InputProcessableFormat::Webp) => Some(Self::Image(ImageFormat::Webp)), + (Self::Animation(_), InputProcessableFormat::Apng) => { + Some(Self::Animation(AnimationFormat::Apng)) + } + (Self::Animation(_), InputProcessableFormat::Avif) => { + Some(Self::Animation(AnimationFormat::Avif)) + } + (Self::Animation(_), InputProcessableFormat::Gif) => { + Some(Self::Animation(AnimationFormat::Gif)) + } + (Self::Animation(_), InputProcessableFormat::Webp) => { + Some(Self::Animation(AnimationFormat::Webp)) + } + (Self::Image(_), InputProcessableFormat::Apng) => None, + (Self::Image(_), InputProcessableFormat::Gif) => None, + (Self::Animation(_), InputProcessableFormat::Jpeg) => None, + (Self::Animation(_), InputProcessableFormat::Jxl) => None, + (Self::Animation(_), InputProcessableFormat::Png) => None, + } + } } impl FromStr for InputProcessableFormat { diff --git a/src/formats/image.rs b/src/formats/image.rs index 1485e60..00c00bb 100644 --- a/src/formats/image.rs +++ b/src/formats/image.rs @@ -1,5 +1,15 @@ #[derive( - Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde::Deserialize, + serde::Serialize, + clap::ValueEnum, )] pub(crate) enum ImageFormat { #[serde(rename = "avif")] diff --git a/src/formats/video.rs b/src/formats/video.rs index e4c1e5b..17e7737 100644 --- a/src/formats/video.rs +++ b/src/formats/video.rs @@ -104,13 +104,6 @@ pub(crate) enum InternalVideoFormat { } 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", @@ -247,13 +240,6 @@ impl OutputVideoFormat { } } - 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", @@ -289,14 +275,7 @@ impl OutputVideoFormat { } } - 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 { + pub(crate) const fn internal_format(self) -> InternalVideoFormat { match self { Self::Mp4 { .. } => InternalVideoFormat::Mp4, Self::Webm { .. } => InternalVideoFormat::Webm, diff --git a/src/generate.rs b/src/generate.rs index 41ce190..765e327 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -48,7 +48,7 @@ pub(crate) async fn generate( async fn process( repo: &R, store: &S, - format: InputProcessableFormat, + output_format: InputProcessableFormat, alias: Alias, thumbnail_path: PathBuf, thumbnail_args: Vec, @@ -83,6 +83,24 @@ async fn process( motion_identifier }; + let input_details = if let Some(details) = repo.details(&identifier).await? { + details + } else { + let details = Details::from_store(store, &identifier).await?; + + repo.relate_details(&identifier, &details).await?; + + details + }; + + let Some(format) = input_details.internal_format().and_then(|format| format.processable_format()) else { + todo!("Error") + }; + + let Some(format) = format.process_to(output_format) else { + todo!("Error") + }; + let mut processed_reader = crate::magick::process_image_store_read(store.clone(), identifier, thumbnail_args, format)?; @@ -95,14 +113,7 @@ async fn process( drop(permit); - 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 details = Details::from_bytes(bytes.clone()).await?; let identifier = store.save_bytes(bytes.clone()).await?; repo.relate_details(&identifier, &details).await?; diff --git a/src/lib.rs b/src/lib.rs index 671c7f8..0acef1d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,7 +33,7 @@ use actix_web::{ web, App, HttpRequest, HttpResponse, HttpResponseBuilder, HttpServer, }; use awc::{Client, Connector}; -use formats::{InputProcessableFormat, ProcessableFormat}; +use formats::InputProcessableFormat; use futures_util::{ stream::{empty, once}, Stream, StreamExt, TryStreamExt, @@ -55,13 +55,12 @@ use tracing_futures::Instrument; use self::{ backgrounded::Backgrounded, - config::{Configuration, ImageFormat, Operation}, + config::{Configuration, Operation}, details::Details, either::Either, error::{Error, UploadError}, ingest::Session, init_tracing::init_tracing, - magick::{details_hint, ValidInputType}, middleware::{Deadline, Internal}, queue::queue_generate, repo::{ @@ -115,15 +114,20 @@ async fn ensure_details( return Err(UploadError::MissingAlias.into()); }; - let details = repo.details(&identifier).await?; + let details = repo.details(&identifier).await?.and_then(|details| { + if details.internal_format().is_some() { + Some(details) + } else { + None + } + }); if let Some(details) = details { tracing::debug!("details exist"); Ok(details) } else { tracing::debug!("generating new details from {:?}", identifier); - let hint = details_hint(alias); - let new_details = Details::from_store(store.clone(), identifier.clone(), hint).await?; + let new_details = Details::from_store(store, &identifier).await?; tracing::debug!("storing details for {:?}", identifier); repo.relate_details(&identifier, &new_details).await?; tracing::debug!("stored"); @@ -645,19 +649,20 @@ async fn process( .await?; if let Some(identifier) = identifier_opt { - let details = repo.details(&identifier).await?; + let details = repo.details(&identifier).await?.and_then(|details| { + if details.internal_format().is_some() { + Some(details) + } else { + None + } + }); let details = if let Some(details) = details { tracing::debug!("details exist"); details } else { tracing::debug!("generating new details from {:?}", identifier); - let new_details = Details::from_store( - (**store).clone(), - identifier.clone(), - Some(ValidInputType::from_format(format)), - ) - .await?; + let new_details = Details::from_store(&store, &identifier).await?; tracing::debug!("storing details for {:?}", identifier); repo.relate_details(&identifier, &new_details).await?; tracing::debug!("stored"); @@ -676,7 +681,7 @@ async fn process( alias, thumbnail_path, thumbnail_args, - original_details.to_input_format(), + original_details.video_format(), None, hash, ) @@ -727,7 +732,7 @@ async fn process_head( repo: web::Data, store: web::Data, ) -> Result { - let (format, alias, thumbnail_path, _) = prepare_process(query, ext.as_str())?; + let (_, alias, thumbnail_path, _) = prepare_process(query, ext.as_str())?; let path_string = thumbnail_path.to_string_lossy().to_string(); let Some(hash) = repo.hash(&alias).await? else { @@ -740,19 +745,20 @@ async fn process_head( .await?; if let Some(identifier) = identifier_opt { - let details = repo.details(&identifier).await?; + let details = repo.details(&identifier).await?.and_then(|details| { + if details.internal_format().is_some() { + Some(details) + } else { + None + } + }); let details = if let Some(details) = details { tracing::debug!("details exist"); details } else { tracing::debug!("generating new details from {:?}", identifier); - let new_details = Details::from_store( - (**store).clone(), - identifier.clone(), - Some(ValidInputType::from_format(format)), - ) - .await?; + let new_details = Details::from_store(&store, &identifier).await?; tracing::debug!("storing details for {:?}", identifier); repo.relate_details(&identifier, &new_details).await?; tracing::debug!("stored"); diff --git a/src/magick.rs b/src/magick.rs index b8135e8..cc79657 100644 --- a/src/magick.rs +++ b/src/magick.rs @@ -2,26 +2,17 @@ mod tests; use crate::{ - config::{ImageFormat, VideoCodec}, formats::ProcessableFormat, process::{Process, ProcessError}, - repo::Alias, store::Store, }; -use actix_web::web::Bytes; -use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::io::AsyncRead; #[derive(Debug, thiserror::Error)] pub(crate) enum MagickError { #[error("Error in imagemagick process")] Process(#[source] ProcessError), - #[error("Error parsing details")] - ParseDetails(#[source] ParseDetailsError), - - #[error("Media details are invalid")] - ValidateDetails(#[source] ValidateDetailsError), - #[error("Invalid output format")] Json(#[source] serde_json::Error), @@ -37,9 +28,6 @@ pub(crate) enum MagickError { #[error("Error creating directory")] CreateDir(#[source] crate::store::file_store::FileError), - #[error("Error reading file")] - Store(#[source] crate::store::StoreError), - #[error("Error closing file")] CloseFile(#[source] std::io::Error), @@ -53,368 +41,8 @@ pub(crate) enum MagickError { impl MagickError { pub(crate) fn is_client_error(&self) -> bool { // Failing validation or imagemagick bailing probably means bad input - matches!(self, Self::ValidateDetails(_)) - || matches!(self, Self::Process(ProcessError::Status(_))) + matches!(self, Self::Process(ProcessError::Status(_))) } - - pub(crate) fn is_not_found(&self) -> bool { - if let Self::Store(e) = self { - return e.is_not_found(); - } - - false - } -} - -pub(crate) fn details_hint(alias: &Alias) -> Option { - let ext = alias.extension()?; - if ext.ends_with(".mp4") { - Some(ValidInputType::Mp4) - } else if ext.ends_with(".webm") { - Some(ValidInputType::Webm) - } else { - None - } -} - -fn image_avif() -> mime::Mime { - "image/avif".parse().unwrap() -} - -fn image_jxl() -> mime::Mime { - "image/jxl".parse().unwrap() -} - -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() -} - -#[derive(Copy, Clone, Debug)] -pub(crate) enum ValidInputType { - Mp4, - Webm, - Gif, - Avif, - Jpeg, - Jxl, - Png, - Webp, -} - -impl ValidInputType { - const fn as_str(self) -> &'static str { - match self { - Self::Mp4 => "MP4", - Self::Webm => "WEBM", - Self::Gif => "GIF", - Self::Avif => "AVIF", - Self::Jpeg => "JPEG", - Self::Jxl => "JXL", - Self::Png => "PNG", - Self::Webp => "WEBP", - } - } - - pub(crate) const fn as_ext(self) -> &'static str { - match self { - Self::Mp4 => ".mp4", - Self::Webm => ".webm", - Self::Gif => ".gif", - Self::Avif => ".avif", - Self::Jpeg => ".jpeg", - Self::Jxl => ".jxl", - Self::Png => ".png", - Self::Webp => ".webp", - } - } - - pub(crate) const fn is_video(self) -> bool { - matches!(self, Self::Mp4 | Self::Webm | Self::Gif) - } - - const fn video_hint(self) -> Option<&'static str> { - match self { - Self::Mp4 => Some(".mp4"), - Self::Webm => Some(".webm"), - Self::Gif => Some(".gif"), - _ => None, - } - } - - pub(crate) const fn from_video_codec(codec: VideoCodec) -> Self { - match codec { - VideoCodec::Av1 | VideoCodec::Vp8 | VideoCodec::Vp9 => Self::Webm, - VideoCodec::H264 | VideoCodec::H265 => Self::Mp4, - } - } - - pub(crate) const fn from_format(format: ImageFormat) -> Self { - match format { - ImageFormat::Avif => ValidInputType::Avif, - ImageFormat::Jpeg => ValidInputType::Jpeg, - ImageFormat::Jxl => ValidInputType::Jxl, - ImageFormat::Png => ValidInputType::Png, - ImageFormat::Webp => ValidInputType::Webp, - } - } - - pub(crate) const fn to_format(self) -> Option { - match self { - Self::Avif => Some(ImageFormat::Avif), - Self::Jpeg => Some(ImageFormat::Jpeg), - Self::Jxl => Some(ImageFormat::Jxl), - Self::Png => Some(ImageFormat::Png), - Self::Webp => Some(ImageFormat::Webp), - _ => None, - } - } -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct Details { - pub(crate) mime_type: mime::Mime, - pub(crate) width: usize, - pub(crate) height: usize, - pub(crate) frames: Option, -} - -#[tracing::instrument(level = "debug", skip(input))] -pub(crate) fn convert_bytes_read( - input: Bytes, - format: ImageFormat, -) -> Result { - let process = Process::run( - "magick", - &[ - "convert", - "-", - "-auto-orient", - "-strip", - format!("{}:-", format.as_magick_format()).as_str(), - ], - ) - .map_err(MagickError::Process)?; - - Ok(process.bytes_read(input)) -} - -#[tracing::instrument(skip(input))] -pub(crate) async fn details_bytes( - input: Bytes, - hint: Option, -) -> Result { - if let Some(hint) = hint.and_then(|hint| hint.video_hint()) { - let input_file = crate::tmp_file::tmp_file(Some(hint)); - 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 mut tmp_one = crate::file::File::create(&input_file) - .await - .map_err(MagickError::CreateFile)?; - tmp_one - .write_from_bytes(input) - .await - .map_err(MagickError::Write)?; - tmp_one.close().await.map_err(MagickError::CloseFile)?; - - return details_file(input_file_str).await; - } - - let last_arg = if let Some(expected_format) = hint { - format!("{}:-", expected_format.as_str()) - } else { - "-".to_owned() - }; - - let process = Process::run("magick", &["convert", "-ping", &last_arg, "JSON:"]) - .map_err(MagickError::Process)?; - - let mut reader = process.bytes_read(input); - - let mut bytes = Vec::new(); - reader - .read_to_end(&mut bytes) - .await - .map_err(MagickError::Read)?; - - let details_output: Vec = - serde_json::from_slice(&bytes).map_err(MagickError::Json)?; - - parse_details(details_output).map_err(MagickError::ParseDetails) -} - -#[derive(Debug, serde::Deserialize)] -struct DetailsOutput { - image: Image, -} - -#[derive(Debug, serde::Deserialize)] -struct Image { - format: String, - geometry: Geometry, -} - -#[derive(Debug, serde::Deserialize)] -struct Geometry { - width: usize, - height: usize, -} - -#[tracing::instrument(skip(store))] -pub(crate) async fn details_store( - store: S, - identifier: S::Identifier, - hint: Option, -) -> Result { - if let Some(hint) = hint.and_then(|hint| hint.video_hint()) { - let input_file = crate::tmp_file::tmp_file(Some(hint)); - 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 mut tmp_one = crate::file::File::create(&input_file) - .await - .map_err(MagickError::CreateFile)?; - let stream = store - .to_stream(&identifier, None, None) - .await - .map_err(MagickError::Store)?; - tmp_one - .write_from_stream(stream) - .await - .map_err(MagickError::Write)?; - tmp_one.close().await.map_err(MagickError::CloseFile)?; - - return details_file(input_file_str).await; - } - - let last_arg = if let Some(expected_format) = hint { - format!("{}:-", expected_format.as_str()) - } else { - "-".to_owned() - }; - - let process = Process::run("magick", &["convert", "-ping", &last_arg, "JSON:"]) - .map_err(MagickError::Process)?; - - let mut reader = process.store_read(store, identifier); - - let mut output = Vec::new(); - reader - .read_to_end(&mut output) - .await - .map_err(MagickError::Read)?; - - let details_output: Vec = - serde_json::from_slice(&output).map_err(MagickError::Json)?; - - parse_details(details_output).map_err(MagickError::ParseDetails) -} - -#[tracing::instrument] -pub(crate) async fn details_file(path_str: &str) -> Result { - let process = Process::run("magick", &["convert", "-ping", path_str, "JSON:"]) - .map_err(MagickError::Process)?; - - let mut reader = process.read(); - - let mut output = Vec::new(); - reader - .read_to_end(&mut output) - .await - .map_err(MagickError::Read)?; - tokio::fs::remove_file(path_str) - .await - .map_err(MagickError::RemoveFile)?; - - let details_output: Vec = - serde_json::from_slice(&output).map_err(MagickError::Json)?; - - parse_details(details_output).map_err(MagickError::ParseDetails) -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum ParseDetailsError { - #[error("No frames present in image")] - NoFrames, - - #[error("Multiple image formats used in same file")] - MixedFormats, - - #[error("Format is unsupported: {0}")] - Unsupported(String), - - #[error("Could not parse frame count from {0}")] - ParseFrames(String), -} - -fn parse_details(details_output: Vec) -> Result { - let frames = details_output.len(); - - if frames == 0 { - return Err(ParseDetailsError::NoFrames); - } - - let width = details_output - .iter() - .map(|details| details.image.geometry.width) - .max() - .expect("Nonempty vector"); - let height = details_output - .iter() - .map(|details| details.image.geometry.height) - .max() - .expect("Nonempty vector"); - - let format = details_output[0].image.format.as_str(); - tracing::debug!("format: {}", format); - - if !details_output - .iter() - .all(|details| details.image.format == format) - { - return Err(ParseDetailsError::MixedFormats); - } - - let mime_type = match format { - "MP4" => video_mp4(), - "WEBM" => video_webm(), - "GIF" => mime::IMAGE_GIF, - "AVIF" => image_avif(), - "JPEG" => mime::IMAGE_JPEG, - "JXL" => image_jxl(), - "PNG" => mime::IMAGE_PNG, - "WEBP" => image_webp(), - e => return Err(ParseDetailsError::Unsupported(String::from(e))), - }; - - Ok(Details { - mime_type, - width, - height, - frames: if frames > 1 { Some(frames) } else { None }, - }) -} - -pub(crate) async fn input_type_bytes( - input: Bytes, -) -> Result<(Details, ValidInputType), MagickError> { - let details = details_bytes(input, None).await?; - let input_type = details - .validate_input() - .map_err(MagickError::ValidateDetails)?; - Ok((details, input_type)) } fn process_image( @@ -462,51 +90,3 @@ pub(crate) fn process_image_async_read( .map_err(MagickError::Process)? .pipe_async_read(async_read)) } - -#[derive(Debug, thiserror::Error)] -pub(crate) enum ValidateDetailsError { - #[error("Exceeded maximum dimensions")] - ExceededDimensions, - - #[error("Exceeded maximum frame count")] - TooManyFrames, - - #[error("Unsupported media type: {0}")] - UnsupportedMediaType(String), -} - -impl Details { - #[tracing::instrument(level = "debug", name = "Validating input type")] - pub(crate) fn validate_input(&self) -> Result { - if self.width > crate::CONFIG.media.max_width - || self.height > crate::CONFIG.media.max_height - || self.width * self.height > crate::CONFIG.media.max_area - { - return Err(ValidateDetailsError::ExceededDimensions); - } - - if let Some(frames) = self.frames { - if frames > crate::CONFIG.media.max_frame_count { - return Err(ValidateDetailsError::TooManyFrames); - } - } - - let input_type = match (self.mime_type.type_(), self.mime_type.subtype()) { - (mime::VIDEO, mime::MP4 | mime::MPEG) => ValidInputType::Mp4, - (mime::VIDEO, subtype) if subtype.as_str() == "webm" => ValidInputType::Webm, - (mime::IMAGE, mime::GIF) => ValidInputType::Gif, - (mime::IMAGE, subtype) if subtype.as_str() == "avif" => ValidInputType::Avif, - (mime::IMAGE, mime::JPEG) => ValidInputType::Jpeg, - (mime::IMAGE, subtype) if subtype.as_str() == "jxl" => ValidInputType::Jxl, - (mime::IMAGE, mime::PNG) => ValidInputType::Png, - (mime::IMAGE, subtype) if subtype.as_str() == "webp" => ValidInputType::Webp, - _ => { - return Err(ValidateDetailsError::UnsupportedMediaType( - self.mime_type.to_string(), - )) - } - }; - - Ok(input_type) - } -} diff --git a/src/queue.rs b/src/queue.rs index 4ed5b0e..6605645 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,6 +1,6 @@ use crate::{ error::Error, - formats::ProcessableFormat, + formats::InputProcessableFormat, repo::{ Alias, AliasRepo, DeleteToken, FullRepo, HashRepo, IdentifierRepo, QueueRepo, UploadId, }, @@ -69,7 +69,7 @@ enum Process { declared_alias: Option>, }, Generate { - target_format: ProcessableFormat, + target_format: InputProcessableFormat, source: Serde, process_path: PathBuf, process_args: Vec, @@ -139,7 +139,7 @@ pub(crate) async fn queue_ingest( pub(crate) async fn queue_generate( repo: &R, - target_format: ProcessableFormat, + target_format: InputProcessableFormat, source: Alias, process_path: PathBuf, process_args: Vec, diff --git a/src/queue/process.rs b/src/queue/process.rs index b26cced..013bfb3 100644 --- a/src/queue/process.rs +++ b/src/queue/process.rs @@ -144,7 +144,7 @@ async fn generate( source, process_path, process_args, - original_details.input_format(), + original_details.video_format(), None, hash, ) diff --git a/src/repo.rs b/src/repo.rs index 4d45f5d..3af7521 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -114,7 +114,7 @@ pub(crate) trait FullRepo: }; match self.details(&identifier).await? { - Some(details) if details.is_motion() => self.motion_identifier::(hash).await, + Some(details) if details.is_video() => self.motion_identifier::(hash).await, Some(_) => Ok(Some(identifier)), None => Ok(None), } diff --git a/src/validate.rs b/src/validate.rs index af347fe..6b44e54 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -10,33 +10,6 @@ use crate::{ use actix_web::web::Bytes; use tokio::io::AsyncRead; -struct UnvalidatedBytes { - bytes: Bytes, - written: usize, -} - -impl UnvalidatedBytes { - fn new(bytes: Bytes) -> Self { - UnvalidatedBytes { bytes, written: 0 } - } -} - -impl AsyncRead for UnvalidatedBytes { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll> { - let bytes_to_write = (self.bytes.len() - self.written).min(buf.remaining()); - if bytes_to_write > 0 { - let end = self.written + bytes_to_write; - buf.put_slice(&self.bytes[self.written..end]); - self.written = end; - } - std::task::Poll::Ready(Ok(())) - } -} - #[tracing::instrument(skip_all)] pub(crate) async fn validate_bytes( bytes: Bytes,