Allow uploading small gifs

This commit is contained in:
asonix 2023-02-04 17:32:36 -06:00
parent d72653cf78
commit 40f57be0c7
10 changed files with 324 additions and 106 deletions

View file

@ -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"

View file

@ -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]

View file

@ -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,
};

View file

@ -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<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
gif: Option<Gif>,
#[serde(skip_serializing_if = "Option::is_none")]
enable_silent_video: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
enable_full_video: Option<bool>,
@ -339,6 +357,17 @@ struct Media {
cache_duration: Option<i64>,
}
#[derive(Debug, Default, serde::Serialize)]
#[serde(rename_all = "snake_case")]
struct Gif {
#[serde(skip_serializing_if = "Option::is_none")]
max_width: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
max_height: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
max_area: Option<usize>,
}
/// 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<usize>,
/// 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<usize>,
/// 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<usize>,
/// 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<usize>,
/// Whether to enable GIF and silent video uploads
#[arg(long)]
media_enable_silent_video: Option<bool>,

View file

@ -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())

View file

@ -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<AudioCodec>,
pub(crate) filters: BTreeSet<String>,
@ -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<Vec<(String, String)>> = OnceCell::new();

View file

@ -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<AudioCodec>,
},
}
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<Process, std::io::Error> {
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<Option<ValidInputType>, Error> {
pub(crate) async fn input_type_bytes(
input: Bytes,
) -> Result<Option<(Details, ValidInputType)>, 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<AudioCodec>,
transcode_options: TranscodeOptions,
) -> Result<impl AsyncRead + Unpin, Error> {
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?;

View file

@ -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() {

View file

@ -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<ImageFormat> {
pub(crate) const fn to_format(self) -> Option<ImageFormat> {
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<Details, Error> {
})
}
pub(crate) async fn input_type_bytes(input: Bytes) -> Result<ValidInputType, Error> {
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<String>, format: ImageFormat) -> std::io::Result<Process> {

View file

@ -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<ImageFormat>,
enable_silent_video: bool,
enable_full_video: bool,
video_codec: VideoCodec,
audio_codec: Option<AudioCodec>,
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?,
))),
))
}