From 40f57be0c734c60d25735f6ef4cf99a767172009 Mon Sep 17 00:00:00 2001 From: asonix Date: Sat, 4 Feb 2023 17:32:36 -0600 Subject: [PATCH] Allow uploading small gifs --- defaults.toml | 37 ++++--- pict-rs.toml | 28 +++++ src/config.rs | 4 +- src/config/commandline.rs | 47 +++++++++ src/config/defaults.rs | 20 ++++ src/config/file.rs | 12 +++ src/ffmpeg.rs | 217 +++++++++++++++++++++++++++++--------- src/ingest.rs | 12 +-- src/magick.rs | 20 ++-- src/validate.rs | 33 +++--- 10 files changed, 324 insertions(+), 106 deletions(-) diff --git a/defaults.toml b/defaults.toml index 1356ed42..7757ca39 100644 --- a/defaults.toml +++ b/defaults.toml @@ -1,20 +1,20 @@ [server] -address = '0.0.0.0:8080' -worker_id = 'pict-rs-1' +address = "0.0.0.0:8080" +worker_id = "pict-rs-1" [tracing.logging] -format = 'normal' -targets = 'warn,tracing_actix_web=info,actix_server=info,actix_web=info' +format = "normal" +targets = "warn,tracing_actix_web=info,actix_server=info,actix_web=info" [tracing.console] buffer_capacity = 102400 [tracing.opentelemetry] -service_name = 'pict-rs' -targets = 'info' +service_name = "pict-rs" +targets = "info" [old_db] -path = '/mnt' +path = "/mnt" [media] max_width = 10000 @@ -24,16 +24,27 @@ max_file_size = 40 max_frame_count = 900 enable_silent_video = true enable_full_video = false -video_codec = 'vp9' -filters = ['blur', 'crop', 'identity', 'resize', 'thumbnail'] +video_codec = "vp9" +filters = [ + "blur", + "crop", + "identity", + "resize", + "thumbnail", +] skip_validate_imports = false cache_duration = 168 +[media.gif] +max_width = 128 +max_height = 128 +max_area = 16384 + [repo] -type = 'sled' -path = '/mnt/sled-repo' +type = "sled" +path = "/mnt/sled-repo" cache_capacity = 67108864 [store] -type = 'filesystem' -path = '/mnt/files' +type = "filesystem" +path = "/mnt/files" diff --git a/pict-rs.toml b/pict-rs.toml index 4511426e..7afa71d5 100644 --- a/pict-rs.toml +++ b/pict-rs.toml @@ -196,6 +196,34 @@ skip_validate_imports = false # default: 168 (1 week) cache_duration = 168 +## Gif configuration +# +# Making any of these bounds 0 will disable gif uploads +[media.gif] +# Optional: Maximum width in pixels for uploaded gifs +# environment variable: PICTRS__MEDIA__GIF__MAX_WIDTH +# default: 128 +# +# If a gif does not fit within this bound, it will either be transcoded to a video or rejected, +# depending on whether video uploads are enabled +max_width = 128 + +# Optional: Maximum height in pixels for uploaded gifs +# environment variable: PICTRS__MEDIA__GIF__MAX_HEIGHT +# default: 128 +# +# If a gif does not fit within this bound, it will either be transcoded to a video or rejected, +# depending on whether video uploads are enabled +max_height = 128 + +# Optional: Maximum area in pixels for uploaded gifs +# environment variable: PICTRS__MEDIA__GIF__MAX_AREA +# default: 16384 (128 * 128) +# +# If a gif does not fit within this bound, it will either be transcoded to a video or rejected, +# depending on whether video uploads are enabled +max_area = 16384 + ## Database configuration [repo] diff --git a/src/config.rs b/src/config.rs index 85eca057..d2a9c061 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,9 @@ use config::Config; use defaults::Defaults; pub(crate) use commandline::Operation; -pub(crate) use file::{ConfigFile as Configuration, OpenTelemetry, Repo, Sled, Tracing}; +pub(crate) use file::{ + ConfigFile as Configuration, Media as MediaConfiguration, OpenTelemetry, Repo, Sled, Tracing, +}; pub(crate) use primitives::{ AudioCodec, Filesystem, ImageFormat, LogFormat, ObjectStorage, Store, VideoCodec, }; diff --git a/src/config/commandline.rs b/src/config/commandline.rs index dc538a49..02c2b15c 100644 --- a/src/config/commandline.rs +++ b/src/config/commandline.rs @@ -52,6 +52,9 @@ impl Args { media_max_area, media_max_file_size, media_max_frame_count, + media_gif_max_width, + media_gif_max_height, + media_gif_max_area, media_enable_silent_video, media_enable_full_video, media_video_codec, @@ -66,6 +69,18 @@ impl Args { api_key, worker_id, }; + let gif = if media_gif_max_width.is_none() + && media_gif_max_height.is_none() + && media_gif_max_area.is_none() + { + None + } else { + Some(Gif { + max_width: media_gif_max_width, + max_height: media_gif_max_height, + max_area: media_gif_max_area, + }) + }; let media = Media { preprocess_steps: media_preprocess_steps, skip_validate_imports: media_skip_validate_imports, @@ -74,6 +89,7 @@ impl Args { max_area: media_max_area, max_file_size: media_max_file_size, max_frame_count: media_max_frame_count, + gif, enable_silent_video: media_enable_silent_video, enable_full_video: media_enable_full_video, video_codec: media_video_codec, @@ -322,6 +338,8 @@ struct Media { #[serde(skip_serializing_if = "Option::is_none")] max_frame_count: Option, #[serde(skip_serializing_if = "Option::is_none")] + gif: Option, + #[serde(skip_serializing_if = "Option::is_none")] enable_silent_video: Option, #[serde(skip_serializing_if = "Option::is_none")] enable_full_video: Option, @@ -339,6 +357,17 @@ struct Media { cache_duration: Option, } +#[derive(Debug, Default, serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct Gif { + #[serde(skip_serializing_if = "Option::is_none")] + max_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_area: Option, +} + /// Run the pict-rs application #[derive(Debug, Parser)] #[command(author, version, about, long_about = None)] @@ -431,6 +460,24 @@ struct Run { /// The maximum number of frames allowed for uploaded GIF and MP4s. #[arg(long)] media_max_frame_count: Option, + /// Maximum width allowed for gif uploads. + /// + /// If an upload exceeds this value, it will be transcoded to a video format or aborted, + /// depending on whether video uploads are enabled. + #[arg(long)] + media_gif_max_width: Option, + /// Maximum height allowed for gif uploads + /// + /// If an upload exceeds this value, it will be transcoded to a video format or aborted, + /// depending on whether video uploads are enabled. + #[arg(long)] + media_gif_max_height: Option, + /// Maximum area allowed for gif uploads + /// + /// If an upload exceeds this value, it will be transcoded to a video format or aborted, + /// depending on whether video uploads are enabled. + #[arg(long)] + media_gif_max_area: Option, /// Whether to enable GIF and silent video uploads #[arg(long)] media_enable_silent_video: Option, diff --git a/src/config/defaults.rs b/src/config/defaults.rs index a0ef18d0..86c642ff 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -66,6 +66,7 @@ struct MediaDefaults { max_area: usize, max_file_size: usize, max_frame_count: usize, + gif: GifDefaults, enable_silent_video: bool, enable_full_video: bool, video_codec: VideoCodec, @@ -74,6 +75,14 @@ struct MediaDefaults { cache_duration: i64, } +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct GifDefaults { + max_height: usize, + max_width: usize, + max_area: usize, +} + #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "snake_case")] #[serde(tag = "type")] @@ -154,6 +163,7 @@ impl Default for MediaDefaults { max_area: 40_000_000, max_file_size: 40, max_frame_count: 900, + gif: Default::default(), enable_silent_video: true, enable_full_video: false, video_codec: VideoCodec::Vp9, @@ -171,6 +181,16 @@ impl Default for MediaDefaults { } } +impl Default for GifDefaults { + fn default() -> Self { + GifDefaults { + max_height: 128, + max_width: 128, + max_area: 16384, + } + } +} + impl Default for RepoDefaults { fn default() -> Self { Self::Sled(SledDefaults::default()) diff --git a/src/config/file.rs b/src/config/file.rs index 242067d5..1a74a44c 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -100,12 +100,15 @@ pub(crate) struct Media { pub(crate) max_frame_count: usize, + pub(crate) gif: Gif, + pub(crate) enable_silent_video: bool, pub(crate) enable_full_video: bool, pub(crate) video_codec: VideoCodec, + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) audio_codec: Option, pub(crate) filters: BTreeSet, @@ -118,6 +121,15 @@ pub(crate) struct Media { pub(crate) cache_duration: i64, } +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub(crate) struct Gif { + pub(crate) max_width: usize, + + pub(crate) max_height: usize, + + pub(crate) max_area: usize, +} + impl Media { pub(crate) fn preprocess_steps(&self) -> Option<&[(String, String)]> { static PREPROCESS_STEPS: OnceCell> = OnceCell::new(); diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index f6034a1f..4645609a 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -1,5 +1,5 @@ use crate::{ - config::{AudioCodec, ImageFormat, VideoCodec}, + config::{AudioCodec, ImageFormat, MediaConfiguration, VideoCodec}, error::{Error, UploadError}, magick::{Details, ValidInputType}, process::Process, @@ -8,6 +8,160 @@ use crate::{ use actix_web::web::Bytes; 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, + }, +} + +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 + { + return Self { + input_format, + output: TranscodeOutputOptions::gif(), + }; + } + } + + Self { + input_format, + output: TranscodeOutputOptions::video(media), + } + } + + const fn input_file_extension(&self) -> &'static str { + self.input_format.to_file_extension() + } + + const fn output_ffmpeg_video_codec(&self) -> &'static str { + match self.output { + TranscodeOutputOptions::Gif => "gif", + TranscodeOutputOptions::Video { video_codec, .. } => video_codec.to_ffmpeg_codec(), + } + } + + const fn output_ffmpeg_audio_codec(&self) -> Option<&'static str> { + match self.output { + TranscodeOutputOptions::Video { + audio_codec: Some(audio_codec), + .. + } => Some(audio_codec.to_ffmpeg_codec()), + _ => None, + } + } + + 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() + } + } + } + + fn execute<'a>( + &self, + input_path: &str, + output_path: &'a str, + ) -> Result { + if let Some(audio_codec) = self.output_ffmpeg_audio_codec() { + Process::run( + "ffmpeg", + &[ + "-i", + input_path, + "-pix_fmt", + "yuv420p", + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:a", + audio_codec, + "-c:v", + self.output_ffmpeg_video_codec(), + "-f", + self.output_ffmpeg_format(), + output_path, + ], + ) + } else { + Process::run( + "ffmpeg", + &[ + "-i", + input_path, + "-pix_fmt", + "yuv420p", + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-an", + "-c:v", + self.output_ffmpeg_video_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 + } +} + #[derive(Clone, Copy, Debug)] pub(crate) enum VideoFormat { Gif, @@ -145,9 +299,12 @@ const FORMAT_MAPPINGS: &[(&str, VideoFormat)] = &[ ("webm", VideoFormat::Webm), ]; -pub(crate) async fn input_type_bytes(input: Bytes) -> Result, Error> { +pub(crate) async fn input_type_bytes( + input: Bytes, +) -> Result, Error> { if let Some(details) = details_bytes(input).await? { - return Ok(Some(details.validate_input()?)); + let input_type = details.validate_input()?; + return Ok(Some((details, input_type))); } Ok(None) @@ -264,19 +421,15 @@ fn parse_details_inner( } #[tracing::instrument(skip(input))] -pub(crate) async fn trancsocde_bytes( +pub(crate) async fn transcode_bytes( input: Bytes, - input_format: VideoFormat, - permit_audio: bool, - video_codec: VideoCodec, - audio_codec: Option, + transcode_options: TranscodeOptions, ) -> Result { - let input_file = crate::tmp_file::tmp_file(Some(input_format.to_file_extension())); + let input_file = crate::tmp_file::tmp_file(Some(transcode_options.input_file_extension())); let input_file_str = input_file.to_str().ok_or(UploadError::Path)?; crate::store::file_store::safe_create_parent(&input_file).await?; - let output_file = - crate::tmp_file::tmp_file(Some(video_codec.to_output_format().to_file_extension())); + let output_file = crate::tmp_file::tmp_file(Some(transcode_options.output_file_extension())); let output_file_str = output_file.to_str().ok_or(UploadError::Path)?; crate::store::file_store::safe_create_parent(&output_file).await?; @@ -284,47 +437,7 @@ pub(crate) async fn trancsocde_bytes( tmp_one.write_from_bytes(input).await?; tmp_one.close().await?; - let output_format = video_codec.to_output_format(); - let audio_codec = audio_codec.unwrap_or_else(|| output_format.default_audio_codec()); - - let process = if permit_audio { - Process::run( - "ffmpeg", - &[ - "-i", - input_file_str, - "-pix_fmt", - "yuv420p", - "-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", - output_format.to_ffmpeg_format(), - output_file_str, - ], - )? - } else { - Process::run( - "ffmpeg", - &[ - "-i", - input_file_str, - "-pix_fmt", - "yuv420p", - "-vf", - "scale=trunc(iw/2)*2:trunc(ih/2)*2", - "-an", - "-c:v", - video_codec.to_ffmpeg_codec(), - "-f", - output_format.to_ffmpeg_format(), - output_file_str, - ], - )? - }; + let process = transcode_options.execute(input_file_str, output_file_str)?; process.wait().await?; tokio::fs::remove_file(input_file).await?; diff --git a/src/ingest.rs b/src/ingest.rs index 4e1e9e16..9bd0d633 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -59,16 +59,8 @@ where let bytes = aggregate(stream).await?; tracing::trace!("Validating bytes"); - let (input_type, validated_reader) = crate::validate::validate_bytes( - bytes, - CONFIG.media.format, - CONFIG.media.enable_silent_video, - CONFIG.media.enable_full_video, - CONFIG.media.video_codec, - CONFIG.media.audio_codec, - should_validate, - ) - .await?; + let (input_type, validated_reader) = + crate::validate::validate_bytes(bytes, &CONFIG.media, should_validate).await?; let processed_reader = if let Some(operations) = CONFIG.media.preprocess_steps() { if let Some(format) = input_type.to_format() { diff --git a/src/magick.rs b/src/magick.rs index cf469157..520c7b25 100644 --- a/src/magick.rs +++ b/src/magick.rs @@ -45,7 +45,7 @@ pub(crate) enum ValidInputType { } impl ValidInputType { - fn as_str(self) -> &'static str { + const fn as_str(self) -> &'static str { match self { Self::Mp4 => "MP4", Self::Webm => "WEBM", @@ -56,7 +56,7 @@ impl ValidInputType { } } - pub(crate) fn as_ext(self) -> &'static str { + pub(crate) const fn as_ext(self) -> &'static str { match self { Self::Mp4 => ".mp4", Self::Webm => ".webm", @@ -67,11 +67,11 @@ impl ValidInputType { } } - pub(crate) fn is_video(self) -> bool { + pub(crate) const fn is_video(self) -> bool { matches!(self, Self::Mp4 | Self::Webm | Self::Gif) } - fn video_hint(self) -> Option<&'static str> { + const fn video_hint(self) -> Option<&'static str> { match self { Self::Mp4 => Some(".mp4"), Self::Webm => Some(".webm"), @@ -80,14 +80,14 @@ impl ValidInputType { } } - pub(crate) fn from_video_codec(codec: VideoCodec) -> Self { + 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) fn from_format(format: ImageFormat) -> Self { + pub(crate) const fn from_format(format: ImageFormat) -> Self { match format { ImageFormat::Jpeg => ValidInputType::Jpeg, ImageFormat::Png => ValidInputType::Png, @@ -95,7 +95,7 @@ impl ValidInputType { } } - pub(crate) fn to_format(self) -> Option { + pub(crate) const fn to_format(self) -> Option { match self { Self::Jpeg => Some(ImageFormat::Jpeg), Self::Png => Some(ImageFormat::Png), @@ -283,8 +283,10 @@ fn parse_details(s: std::borrow::Cow<'_, str>) -> Result { }) } -pub(crate) async fn input_type_bytes(input: Bytes) -> Result { - details_bytes(input, None).await?.validate_input() +pub(crate) async fn input_type_bytes(input: Bytes) -> Result<(Details, ValidInputType), Error> { + let details = details_bytes(input, None).await?; + let input_type = details.validate_input()?; + Ok((details, input_type)) } fn process_image(args: Vec, format: ImageFormat) -> std::io::Result { diff --git a/src/validate.rs b/src/validate.rs index ae76e54b..ea80c238 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,8 +1,8 @@ use crate::{ - config::{AudioCodec, ImageFormat, VideoCodec}, + config::{ImageFormat, MediaConfiguration}, either::Either, error::{Error, UploadError}, - ffmpeg::FileFormat, + ffmpeg::{FileFormat, TranscodeOptions}, magick::ValidInputType, }; use actix_web::web::Bytes; @@ -38,16 +38,12 @@ impl AsyncRead for UnvalidatedBytes { #[tracing::instrument(skip_all)] pub(crate) async fn validate_bytes( bytes: Bytes, - prescribed_format: Option, - enable_silent_video: bool, - enable_full_video: bool, - video_codec: VideoCodec, - audio_codec: Option, + media: &MediaConfiguration, validate: bool, ) -> Result<(ValidInputType, impl AsyncRead + Unpin), Error> { - let input_type = - if let Some(input_type) = crate::ffmpeg::input_type_bytes(bytes.clone()).await? { - input_type + 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? }; @@ -56,22 +52,17 @@ pub(crate) async fn validate_bytes( return Ok((input_type, Either::left(UnvalidatedBytes::new(bytes)))); } - match (input_type.to_file_format(), prescribed_format) { + match (input_type.to_file_format(), media.format) { (FileFormat::Video(video_format), _) => { - if !(enable_silent_video || enable_full_video) { + if !(media.enable_silent_video || media.enable_full_video) { return Err(UploadError::SilentVideoDisabled.into()); } + let transcode_options = TranscodeOptions::new(media, &details, video_format); + Ok(( - ValidInputType::from_video_codec(video_codec), + transcode_options.output_type(), Either::right(Either::left(Either::left( - crate::ffmpeg::trancsocde_bytes( - bytes, - video_format, - enable_full_video, - video_codec, - audio_codec, - ) - .await?, + crate::ffmpeg::transcode_bytes(bytes, transcode_options).await?, ))), )) }