Video support

This commit is contained in:
Bad Manners 2025-04-12 11:42:05 -03:00
parent 4960527af3
commit c650c27825
14 changed files with 310 additions and 57 deletions

View file

@ -2,9 +2,12 @@
Sam's small image board. Currently a WIP. Sam's small image board. Currently a WIP.
## Prerequisites
- `ffmpeg` and `ffprobe`
## TODO ## TODO
- [ ] Video support
- [ ] Cleanup/fixup background tasks - [ ] Cleanup/fixup background tasks
- [ ] User management - [ ] User management
- [ ] CSS - [ ] CSS

View file

@ -86,7 +86,14 @@ impl MigrationTrait for Migration {
.col(string_len(SameyPost::Media, 255)) .col(string_len(SameyPost::Media, 255))
.col(integer(SameyPost::Width)) .col(integer(SameyPost::Width))
.col(integer(SameyPost::Height)) .col(integer(SameyPost::Height))
.col(enumeration(
SameyPost::MediaType,
MediaType::Enum,
[MediaType::Image, MediaType::Video],
))
.col(string_len(SameyPost::Thumbnail, 255)) .col(string_len(SameyPost::Thumbnail, 255))
.col(integer(SameyPost::ThumbnailWidth))
.col(integer(SameyPost::ThumbnailHeight))
.col(string_len_null(SameyPost::Title, 100)) .col(string_len_null(SameyPost::Title, 100))
.col(text_null(SameyPost::Description)) .col(text_null(SameyPost::Description))
.col(boolean(SameyPost::IsPublic).default(false)) .col(boolean(SameyPost::IsPublic).default(false))
@ -291,7 +298,10 @@ enum SameyPost {
Media, Media,
Width, Width,
Height, Height,
MediaType,
Thumbnail, Thumbnail,
ThumbnailWidth,
ThumbnailHeight,
Title, Title,
Description, Description,
IsPublic, IsPublic,
@ -300,6 +310,17 @@ enum SameyPost {
ParentId, 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)] #[derive(DeriveIden)]
#[sea_orm(enum_name = "rating")] #[sea_orm(enum_name = "rating")]
pub enum Rating { pub enum Rating {

View file

@ -11,7 +11,11 @@ pub struct Model {
pub media: String, pub media: String,
pub width: i32, pub width: i32,
pub height: i32, pub height: i32,
#[sea_orm(column_type = "custom(\"enum_text\")")]
pub media_type: String,
pub thumbnail: String, pub thumbnail: String,
pub thumbnail_width: i32,
pub thumbnail_height: i32,
pub title: Option<String>, pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>, pub description: Option<String>,

View file

@ -4,6 +4,7 @@ pub(crate) mod entities;
pub(crate) mod error; pub(crate) mod error;
pub(crate) mod query; pub(crate) mod query;
pub(crate) mod rating; pub(crate) mod rating;
pub(crate) mod video;
pub(crate) mod views; pub(crate) mod views;
use std::sync::Arc; use std::sync::Arc;

View file

@ -20,6 +20,7 @@ pub(crate) struct PostOverview {
pub(crate) id: i32, pub(crate) id: i32,
pub(crate) thumbnail: String, pub(crate) thumbnail: String,
pub(crate) tags: String, pub(crate) tags: String,
pub(crate) media_type: String,
pub(crate) rating: String, pub(crate) rating: String,
} }
@ -54,6 +55,7 @@ pub(crate) fn search_posts(
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating) .column(samey_post::Column::Rating)
.column(samey_post::Column::MediaType)
.column_as( .column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags", "tags",
@ -76,6 +78,7 @@ pub(crate) fn search_posts(
.column(samey_post::Column::Id) .column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail) .column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating) .column(samey_post::Column::Rating)
.column(samey_post::Column::MediaType)
.column_as( .column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags", "tags",

73
src/video.rs Normal file
View file

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

View file

@ -4,6 +4,7 @@ use std::{
fs::OpenOptions, fs::OpenOptions,
io::{BufReader, Seek, Write}, io::{BufReader, Seek, Write},
num::NonZero, num::NonZero,
str::FromStr,
}; };
use askama::Template; use askama::Template;
@ -22,7 +23,7 @@ use sea_orm::{
ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, ModelTrait, PaginatorTrait, QueryFilter, QuerySelect,
}; };
use serde::Deserialize; use serde::Deserialize;
use tokio::task::spawn_blocking; use tokio::{task::spawn_blocking, try_join};
use crate::{ use crate::{
AppState, NEGATIVE_PREFIX, RATING_PREFIX, AppState, NEGATIVE_PREFIX, RATING_PREFIX,
@ -40,6 +41,7 @@ use crate::{
query::{ query::{
PoolPost, PostOverview, filter_by_user, get_posts_in_pool, get_tags_for_post, search_posts, 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; const MAX_THUMBNAIL_DIMENSION: u32 = 192;
@ -98,6 +100,39 @@ pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoRes
// Post upload view // Post upload view
enum Format {
Video(&'static str),
Image(ImageFormat),
}
impl Format {
fn media_type(&self) -> &'static str {
match self {
Format::Video(_) => "video",
Format::Image(_) => "image",
}
}
}
impl FromStr for Format {
type Err = SameyError;
fn from_str(content_type: &str) -> Result<Self, Self::Err> {
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( pub(crate) async fn upload(
State(AppState { db, files_dir, .. }): State<AppState>, State(AppState { db, files_dir, .. }): State<AppState>,
auth_session: AuthSession, auth_session: AuthSession,
@ -110,9 +145,12 @@ pub(crate) async fn upload(
let mut upload_tags: Option<Vec<samey_tag::Model>> = None; let mut upload_tags: Option<Vec<samey_tag::Model>> = None;
let mut source_file: Option<String> = None; let mut source_file: Option<String> = None;
let mut thumbnail_file: Option<String> = None; let mut media_type: Option<&'static str> = None;
let mut width: Option<NonZero<i32>> = None; let mut width: Option<NonZero<i32>> = None;
let mut height: Option<NonZero<i32>> = None; let mut height: Option<NonZero<i32>> = None;
let mut thumbnail_file: Option<String> = None;
let mut thumbnail_width: Option<NonZero<i32>> = None;
let mut thumbnail_height: Option<NonZero<i32>> = None;
let base_path = std::path::Path::new(files_dir.as_ref()); let base_path = std::path::Path::new(files_dir.as_ref());
// Read multipart form data // Read multipart form data
@ -143,73 +181,150 @@ pub(crate) async fn upload(
); );
} }
} }
"media-file" => { "media-file" => {
let content_type = field let content_type = field
.content_type() .content_type()
.ok_or(SameyError::Other("Missing content type".into()))?; .ok_or(SameyError::Other("Missing content type".into()))?;
let format = ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other( match Format::from_str(content_type)? {
format!("Unknown content type: {}", content_type), format @ Format::Video(video_format) => {
))?; media_type = Some(format.media_type());
let file_name = { let thumbnail_format = ImageFormat::Png;
let mut rng = rand::rng(); let (file_name, thumbnail_file_name) = {
let mut file_name: String = (0..8) let mut rng = rand::rng();
.map(|_| rng.sample(rand::distr::Alphanumeric) as char) let mut file_name: String = (0..8)
.collect(); .map(|_| rng.sample(rand::distr::Alphanumeric) as char)
file_name.push('.'); .collect();
file_name.push_str(format.extensions_str()[0]); let thumbnail_file_name = format!(
file_name "thumb-{}.{}",
}; file_name,
let thumbnail_file_name = format!("thumb-{}", file_name); thumbnail_format.extensions_str()[0]
let file_path = base_path.join(&file_name); );
let mut file = OpenOptions::new() file_name.push_str(video_format);
.read(true) (file_name, thumbnail_file_name)
.write(true) };
.create(true) let file_path = base_path.join(&file_name);
.open(&file_path)?; let mut file = OpenOptions::new()
while let Some(chunk) = field.chunk().await? { .read(true)
file.write_all(&chunk)?; .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, upload_tags,
source_file, source_file,
media_type,
thumbnail_file, thumbnail_file,
width.map(|w| w.get()), width.map(|w| w.get()),
height.map(|h| h.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 { let uploaded_post = SameyPost::insert(samey_post::ActiveModel {
uploader_id: Set(user.id), uploader_id: Set(user.id),
media: Set(source_file), media: Set(source_file),
media_type: Set(media_type.into()),
width: Set(width), width: Set(width),
height: Set(height), height: Set(height),
thumbnail: Set(thumbnail_file), thumbnail: Set(thumbnail_file),
thumbnail_width: Set(thumbnail_width),
thumbnail_height: Set(thumbnail_height),
title: Set(None), title: Set(None),
description: Set(None), description: Set(None),
rating: Set("u".to_owned()), rating: Set("u".to_owned()),
@ -934,6 +1049,7 @@ pub(crate) async fn view_post(
.map(|tag| &tag.name) .map(|tag| &tag.name)
.join(" "), .join(" "),
rating: parent_post.rating, rating: parent_post.rating,
media_type: parent_post.media_type,
}), }),
None => None, None => None,
} }
@ -960,6 +1076,7 @@ pub(crate) async fn view_post(
.map(|tag| &tag.name) .map(|tag| &tag.name)
.join(" "), .join(" "),
rating: child_post.rating, 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) .map(|tag| &tag.name)
.join(" "), .join(" "),
rating: parent_post.rating, rating: parent_post.rating,
media_type: parent_post.media_type,
}), }),
None => None, None => None,
} }
@ -1263,7 +1381,7 @@ pub(crate) async fn remove_field() -> impl IntoResponse {
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "get_media.html")] #[template(path = "get_image_media.html")]
struct GetMediaTemplate { struct GetMediaTemplate {
post: samey_post::Model, post: samey_post::Model,
} }
@ -1291,7 +1409,7 @@ pub(crate) async fn get_media(
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "get_full_media.html")] #[template(path = "get_full_image_media.html")]
struct GetFullMediaTemplate { struct GetFullMediaTemplate {
post: samey_post::Model, post: samey_post::Model,
} }

View file

@ -0,0 +1,6 @@
<video
id="media"
src="/files/{{ post.media }}"
controls="controls"
style="width: 100%; height: 100%; max-width: {{ post.width }}px; max-height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}"
/>

View file

@ -53,7 +53,7 @@
type="file" type="file"
id="media-file" id="media-file"
name="media-file" name="media-file"
accept=".jpg, .jpeg, .png, .webp, .gif" accept=".jpg, .jpeg, .png, .webp, .gif, .mp4, .webm, .mkv, .mov"
/> />
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>

View file

@ -32,6 +32,10 @@
</ul> </ul>
{% endif %} {% endif %}
</div> </div>
<div>
<label>Type</label>
{{ post.media_type | capitalize }}
</div>
<div> <div>
<label>Width</label> <label>Width</label>
{{ post.width }}px {{ post.width }}px

View file

@ -49,6 +49,7 @@
> >
<img src="/files/{{ post.thumbnail }}" /> <img src="/files/{{ post.thumbnail }}" />
<div>{{ post.rating | upper }}</div> <div>{{ post.rating | upper }}</div>
<div>{{ post.media_type }}</div>
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View file

@ -6,15 +6,12 @@
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>Post #{{ post.id }} - {{ application_name }}</title> <title>Post #{{ post.id }} - {{ application_name }}</title>
<meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" /> <meta name="generator" content="Samey {{ env!("CARGO_PKG_VERSION") }}" />
<meta property="og:site_name" content="{{ application_name }}" />
<meta <meta
property="og:title" property="og:title"
content="{% if let Some(title) = post.title %}{{ title }}{% endif %}" content="{% if let Some(title) = post.title %}{{ title }}{% endif %}"
/> />
<meta property="og:url" content="/post/{{ post.id }}" /> <meta property="og:url" content="/post/{{ post.id }}" />
<meta property="og:image" content="/files/{{ post.media }}" />
<meta property="og:image:width" content="{{ post.width }}" />
<meta property="og:image:height" content="{{ post.height }}" />
<meta property="og:image:alt" content="{{ tags_text }}" />
<meta <meta
property="og:description" property="og:description"
content="{% if let Some(description) = post.description %}{{ description }}{% endif %}" content="{% if let Some(description) = post.description %}{{ description }}{% endif %}"
@ -23,13 +20,33 @@
property="twitter:title" property="twitter:title"
content="{% if let Some(title) = post.title %}{{ title }}{% endif %}" content="{% if let Some(title) = post.title %}{{ title }}{% endif %}"
/> />
<meta property="twitter:image:src" content="/files/{{ post.media }}" /> {% match post.media_type.as_ref() %} {% when "image" %}
<meta property="og:image" content="/files/{{ post.media }}" />
<meta property="og:image:width" content="{{ post.width }}" />
<meta property="og:image:height" content="{{ post.height }}" />
<meta property="og:image:alt" content="{{ tags_text }}" />
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
{% when "video" %}
<meta property="og:video" content="/files/{{ post.media }}" />
<meta property="og:video:width" content="{{ post.width }}" />
<meta property="og:video:height" content="{{ post.height }}" />
<meta property="og:video:alt" content="{{ tags_text }}" />
<meta property="og:video:type" content="video/mp4" />
<meta property="twitter:card" content="player" />
<meta name="twitter:player" content="/files/{{ post.media }}" />
<meta name="twitter:player:width" content="{{ post.width }}" />
<meta name="twitter:player:height" content="{{ post.height }}" />
<meta name="twitter:image" content="/files/{{ post.thumbnail }}" />
{% else %} {% endmatch %}
</head> </head>
<body> <body>
<main> <main>
<h1>View post #{{ post.id }}</h1> <h1>View post #{{ post.id }}</h1>
<div>{% include "get_media.html" %}</div> <div>
{% match post.media_type.as_ref() %}{% when "image" %}{% include
"get_image_media.html" %}{% when "video" %}{% include
"get_video_media.html" %}{% else %}{% endmatch %}
</div>
</main> </main>
<article> <article>
<h2>Details</h2> <h2>Details</h2>
@ -41,6 +58,7 @@
<a href="/post/{{ parent_post.id }}" title="{{ parent_post.tags }}"> <a href="/post/{{ parent_post.id }}" title="{{ parent_post.tags }}">
<img src="/files/{{ parent_post.thumbnail }}" /> <img src="/files/{{ parent_post.thumbnail }}" />
<div>{{ parent_post.rating }}</div> <div>{{ parent_post.rating }}</div>
<div>{{ parent_post.media_type }}</div>
</a> </a>
</article> </article>
{% else %} {% else %}
@ -54,6 +72,7 @@
<a href="/post/{{ child_post.id }}" title="{{ child_post.tags }}"> <a href="/post/{{ child_post.id }}" title="{{ child_post.tags }}">
<img src="/files/{{ child_post.thumbnail }}" /> <img src="/files/{{ child_post.thumbnail }}" />
<div>{{ child_post.rating | upper }}</div> <div>{{ child_post.rating | upper }}</div>
<div>{{ child_post.media_type }}</div>
</a> </a>
</li> </li>
{% endfor %} {% endfor %}