diff --git a/README.md b/README.md index 5e9fb00..38245b1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,12 @@ Sam's small image board. Currently a WIP. +## Prerequisites + +- `ffmpeg` and `ffprobe` + ## TODO -- [ ] Video support - [ ] Cleanup/fixup background tasks - [ ] User management - [ ] CSS diff --git a/migration/src/m20250405_000001_create_table.rs b/migration/src/m20250405_000001_create_table.rs index ff3644e..b106c0b 100644 --- a/migration/src/m20250405_000001_create_table.rs +++ b/migration/src/m20250405_000001_create_table.rs @@ -86,7 +86,14 @@ impl MigrationTrait for Migration { .col(string_len(SameyPost::Media, 255)) .col(integer(SameyPost::Width)) .col(integer(SameyPost::Height)) + .col(enumeration( + SameyPost::MediaType, + MediaType::Enum, + [MediaType::Image, MediaType::Video], + )) .col(string_len(SameyPost::Thumbnail, 255)) + .col(integer(SameyPost::ThumbnailWidth)) + .col(integer(SameyPost::ThumbnailHeight)) .col(string_len_null(SameyPost::Title, 100)) .col(text_null(SameyPost::Description)) .col(boolean(SameyPost::IsPublic).default(false)) @@ -291,7 +298,10 @@ enum SameyPost { Media, Width, Height, + MediaType, Thumbnail, + ThumbnailWidth, + ThumbnailHeight, Title, Description, IsPublic, @@ -300,6 +310,17 @@ enum SameyPost { ParentId, } +#[derive(DeriveIden)] +#[sea_orm(enum_name = "media_type")] +pub enum MediaType { + #[sea_orm(iden = "media_type")] + Enum, + #[sea_orm(iden = "image")] + Image, + #[sea_orm(iden = "video")] + Video, +} + #[derive(DeriveIden)] #[sea_orm(enum_name = "rating")] pub enum Rating { diff --git a/src/entities/samey_post.rs b/src/entities/samey_post.rs index b810ecd..38ad781 100644 --- a/src/entities/samey_post.rs +++ b/src/entities/samey_post.rs @@ -11,7 +11,11 @@ pub struct Model { pub media: String, pub width: i32, pub height: i32, + #[sea_orm(column_type = "custom(\"enum_text\")")] + pub media_type: String, pub thumbnail: String, + pub thumbnail_width: i32, + pub thumbnail_height: i32, pub title: Option, #[sea_orm(column_type = "Text", nullable)] pub description: Option, diff --git a/src/lib.rs b/src/lib.rs index dfef02f..77e65bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod entities; pub(crate) mod error; pub(crate) mod query; pub(crate) mod rating; +pub(crate) mod video; pub(crate) mod views; use std::sync::Arc; diff --git a/src/query.rs b/src/query.rs index 9802c59..6d6d9fc 100644 --- a/src/query.rs +++ b/src/query.rs @@ -20,6 +20,7 @@ pub(crate) struct PostOverview { pub(crate) id: i32, pub(crate) thumbnail: String, pub(crate) tags: String, + pub(crate) media_type: String, pub(crate) rating: String, } @@ -54,6 +55,7 @@ pub(crate) fn search_posts( .column(samey_post::Column::Id) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) + .column(samey_post::Column::MediaType) .column_as( Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), "tags", @@ -76,6 +78,7 @@ pub(crate) fn search_posts( .column(samey_post::Column::Id) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) + .column(samey_post::Column::MediaType) .column_as( Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), "tags", diff --git a/src/video.rs b/src/video.rs new file mode 100644 index 0000000..554ea3c --- /dev/null +++ b/src/video.rs @@ -0,0 +1,73 @@ +use std::process::{Command, Stdio}; + +use crate::SameyError; + +pub(crate) fn generate_thumbnail( + input_path: &str, + output_path: &str, + max_thumbnail_dimension: u32, +) -> Result<(), SameyError> { + let status = Command::new("ffmpeg") + .args(&[ + "-i", + input_path, + "-vf", + "thumbnail", + "-vf", + &format!( + "scale={}:{}:force_original_aspect_ratio=decrease", + max_thumbnail_dimension, max_thumbnail_dimension + ), + "-frames:v", + "1", + "-q:v", + "2", // Quality (2 is good) + output_path, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(SameyError::Other( + "FFmpeg failed to generate thumbnail".into(), + )) + } +} + +pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> { + let output = Command::new("ffprobe") + .args(&[ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "default=nw=1:nk=1", + input_path, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output()?; + + if !output.status.success() { + return Err(SameyError::Other( + "FFprobe failed to get dimensions for video".into(), + )); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + + let mut dimensions = output_str + .lines() + .filter_map(|line| line.trim().parse().ok()); + + match (dimensions.next(), dimensions.next()) { + (Some(width), Some(height)) => Ok((width, height)), + _ => Err(SameyError::Other("Failed to parse FFprobe output".into())), + } +} diff --git a/src/views.rs b/src/views.rs index d2f59c9..def1eb5 100644 --- a/src/views.rs +++ b/src/views.rs @@ -4,6 +4,7 @@ use std::{ fs::OpenOptions, io::{BufReader, Seek, Write}, num::NonZero, + str::FromStr, }; use askama::Template; @@ -22,7 +23,7 @@ use sea_orm::{ ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, }; use serde::Deserialize; -use tokio::task::spawn_blocking; +use tokio::{task::spawn_blocking, try_join}; use crate::{ AppState, NEGATIVE_PREFIX, RATING_PREFIX, @@ -40,6 +41,7 @@ use crate::{ query::{ PoolPost, PostOverview, filter_by_user, get_posts_in_pool, get_tags_for_post, search_posts, }, + video::{generate_thumbnail, get_dimensions_for_video}, }; const MAX_THUMBNAIL_DIMENSION: u32 = 192; @@ -98,6 +100,39 @@ pub(crate) async fn logout(mut auth_session: AuthSession) -> Result &'static str { + match self { + Format::Video(_) => "video", + Format::Image(_) => "image", + } + } +} + +impl FromStr for Format { + type Err = SameyError; + + fn from_str(content_type: &str) -> Result { + match content_type { + "video/mp4" => Ok(Self::Video(".mp4")), + "video/webm" => Ok(Self::Video(".webm")), + "application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")), + "video/quicktime" => Ok(Self::Video(".mov")), + _ => Ok(Self::Image( + ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!( + "Unknown content type: {}", + content_type + )))?, + )), + } + } +} + pub(crate) async fn upload( State(AppState { db, files_dir, .. }): State, auth_session: AuthSession, @@ -110,9 +145,12 @@ pub(crate) async fn upload( let mut upload_tags: Option> = None; let mut source_file: Option = None; - let mut thumbnail_file: Option = None; + let mut media_type: Option<&'static str> = None; let mut width: Option> = None; let mut height: Option> = None; + let mut thumbnail_file: Option = None; + let mut thumbnail_width: Option> = None; + let mut thumbnail_height: Option> = None; let base_path = std::path::Path::new(files_dir.as_ref()); // Read multipart form data @@ -143,73 +181,150 @@ pub(crate) async fn upload( ); } } + "media-file" => { let content_type = field .content_type() .ok_or(SameyError::Other("Missing content type".into()))?; - let format = ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other( - format!("Unknown content type: {}", content_type), - ))?; - let file_name = { - let mut rng = rand::rng(); - let mut file_name: String = (0..8) - .map(|_| rng.sample(rand::distr::Alphanumeric) as char) - .collect(); - file_name.push('.'); - file_name.push_str(format.extensions_str()[0]); - file_name - }; - let thumbnail_file_name = format!("thumb-{}", file_name); - let file_path = base_path.join(&file_name); - let mut file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(&file_path)?; - while let Some(chunk) = field.chunk().await? { - file.write_all(&chunk)?; + match Format::from_str(content_type)? { + format @ Format::Video(video_format) => { + media_type = Some(format.media_type()); + let thumbnail_format = ImageFormat::Png; + let (file_name, thumbnail_file_name) = { + let mut rng = rand::rng(); + let mut file_name: String = (0..8) + .map(|_| rng.sample(rand::distr::Alphanumeric) as char) + .collect(); + let thumbnail_file_name = format!( + "thumb-{}.{}", + file_name, + thumbnail_format.extensions_str()[0] + ); + file_name.push_str(video_format); + (file_name, thumbnail_file_name) + }; + let file_path = base_path.join(&file_name); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&file_path)?; + while let Some(chunk) = field.chunk().await? { + file.write_all(&chunk)?; + } + let file_path_2 = file_path.to_string_lossy().into_owned(); + let thumbnail_path = base_path.join(&thumbnail_file_name); + let jh_thumbnail = spawn_blocking(move || { + generate_thumbnail( + &file_path_2, + &thumbnail_path.to_string_lossy(), + MAX_THUMBNAIL_DIMENSION, + )?; + let mut image = ImageReader::new(BufReader::new( + OpenOptions::new().read(true).open(thumbnail_path)?, + )); + image.set_format(thumbnail_format); + Ok(image.into_dimensions()?) + }); + let file_path_2 = file_path.to_string_lossy().into_owned(); + let jh_video = + spawn_blocking(move || get_dimensions_for_video(&file_path_2)); + let (dim_thumbnail, dim_video) = match try_join!(jh_thumbnail, jh_video)? { + (Ok(dim_thumbnail), Ok(dim_video)) => (dim_thumbnail, dim_video), + (Err(err), _) | (_, Err(err)) => return Err(err), + }; + width = NonZero::new(dim_video.0.try_into()?); + height = NonZero::new(dim_video.1.try_into()?); + thumbnail_width = NonZero::new(dim_thumbnail.0.try_into()?); + thumbnail_height = NonZero::new(dim_thumbnail.1.try_into()?); + source_file = Some(file_name); + thumbnail_file = Some(thumbnail_file_name); + } + + format @ Format::Image(image_format) => { + media_type = Some(format.media_type()); + let file_name = { + let mut rng = rand::rng(); + let mut file_name: String = (0..8) + .map(|_| rng.sample(rand::distr::Alphanumeric) as char) + .collect(); + file_name.push('.'); + file_name.push_str(image_format.extensions_str()[0]); + file_name + }; + let thumbnail_file_name = format!("thumb-{}", file_name); + let file_path = base_path.join(&file_name); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&file_path)?; + while let Some(chunk) = field.chunk().await? { + file.write_all(&chunk)?; + } + let base_path_2 = base_path.to_owned(); + let thumbnail_path = base_path_2.join(&thumbnail_file_name); + let (w, h, tw, th) = spawn_blocking(move || -> Result<_, SameyError> { + file.seek(std::io::SeekFrom::Start(0))?; + let mut image = ImageReader::new(BufReader::new(file)); + image.set_format(image_format); + let image = image.decode()?; + let (w, h) = image.dimensions(); + let width = NonZero::new(w.try_into()?); + let height = NonZero::new(h.try_into()?); + let thumbnail = image.resize( + MAX_THUMBNAIL_DIMENSION, + MAX_THUMBNAIL_DIMENSION, + image::imageops::FilterType::CatmullRom, + ); + thumbnail.save(thumbnail_path)?; + let (tw, th) = image.dimensions(); + let thumbnail_width = NonZero::new(tw.try_into()?); + let thumbnail_height = NonZero::new(th.try_into()?); + Ok((width, height, thumbnail_width, thumbnail_height)) + }) + .await??; + width = w; + height = h; + thumbnail_width = tw; + thumbnail_height = th; + source_file = Some(file_name); + thumbnail_file = Some(thumbnail_file_name); + } } - let base_path_2 = base_path.to_owned(); - let (w, h, thumbnail_file_name) = - spawn_blocking(move || -> Result<_, SameyError> { - file.seek(std::io::SeekFrom::Start(0))?; - let mut image = ImageReader::new(BufReader::new(file)); - image.set_format(format); - let image = image.decode()?; - let (w, h) = image.dimensions(); - let width = NonZero::new(w.try_into()?); - let height = NonZero::new(h.try_into()?); - let thumbnail = image.resize( - MAX_THUMBNAIL_DIMENSION, - MAX_THUMBNAIL_DIMENSION, - image::imageops::FilterType::CatmullRom, - ); - thumbnail.save(base_path_2.join(&thumbnail_file_name))?; - Ok((width, height, thumbnail_file_name)) - }) - .await??; - width = w; - height = h; - source_file = Some(file_name); - thumbnail_file = Some(thumbnail_file_name); } _ => (), } } - if let (Some(upload_tags), Some(source_file), Some(thumbnail_file), Some(width), Some(height)) = ( + if let ( + Some(upload_tags), + Some(source_file), + Some(media_type), + Some(thumbnail_file), + Some(width), + Some(height), + Some(thumbnail_width), + Some(thumbnail_height), + ) = ( upload_tags, source_file, + media_type, thumbnail_file, width.map(|w| w.get()), height.map(|h| h.get()), + thumbnail_width.map(|w| w.get()), + thumbnail_height.map(|h| h.get()), ) { let uploaded_post = SameyPost::insert(samey_post::ActiveModel { uploader_id: Set(user.id), media: Set(source_file), + media_type: Set(media_type.into()), width: Set(width), height: Set(height), thumbnail: Set(thumbnail_file), + thumbnail_width: Set(thumbnail_width), + thumbnail_height: Set(thumbnail_height), title: Set(None), description: Set(None), rating: Set("u".to_owned()), @@ -934,6 +1049,7 @@ pub(crate) async fn view_post( .map(|tag| &tag.name) .join(" "), rating: parent_post.rating, + media_type: parent_post.media_type, }), None => None, } @@ -960,6 +1076,7 @@ pub(crate) async fn view_post( .map(|tag| &tag.name) .join(" "), rating: child_post.rating, + media_type: child_post.media_type, }); } @@ -1094,6 +1211,7 @@ pub(crate) async fn submit_post_details( .map(|tag| &tag.name) .join(" "), rating: parent_post.rating, + media_type: parent_post.media_type, }), None => None, } @@ -1263,7 +1381,7 @@ pub(crate) async fn remove_field() -> impl IntoResponse { } #[derive(Template)] -#[template(path = "get_media.html")] +#[template(path = "get_image_media.html")] struct GetMediaTemplate { post: samey_post::Model, } @@ -1291,7 +1409,7 @@ pub(crate) async fn get_media( } #[derive(Template)] -#[template(path = "get_full_media.html")] +#[template(path = "get_full_image_media.html")] struct GetFullMediaTemplate { post: samey_post::Model, } diff --git a/templates/get_full_media.html b/templates/get_full_image_media.html similarity index 100% rename from templates/get_full_media.html rename to templates/get_full_image_media.html diff --git a/templates/get_media.html b/templates/get_image_media.html similarity index 100% rename from templates/get_media.html rename to templates/get_image_media.html diff --git a/templates/get_video_media.html b/templates/get_video_media.html new file mode 100644 index 0000000..37cf898 --- /dev/null +++ b/templates/get_video_media.html @@ -0,0 +1,6 @@ +