diff --git a/.gitignore b/.gitignore index dfb3505..90251d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ target/ .env* !.env.example uploads/ -videos/ +/videos diff --git a/Cargo.lock b/Cargo.lock index 688454f..f5bff92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2545,6 +2545,7 @@ name = "vod" version = "0.1.0" dependencies = [ "anyhow", + "tracing", ] [[package]] diff --git a/packages/db/migrations/20241109231211_video_privacy.down.sql b/packages/db/migrations/20241109231211_video_privacy.down.sql new file mode 100644 index 0000000..bdb1764 --- /dev/null +++ b/packages/db/migrations/20241109231211_video_privacy.down.sql @@ -0,0 +1,6 @@ +-- Remove the privacy_status column from videos table +ALTER TABLE videos +DROP COLUMN privacy_status; + +-- Remove the privacy_status enum type +DROP TYPE privacy_status; diff --git a/packages/db/migrations/20241109231211_video_privacy.up.sql b/packages/db/migrations/20241109231211_video_privacy.up.sql new file mode 100644 index 0000000..30c4356 --- /dev/null +++ b/packages/db/migrations/20241109231211_video_privacy.up.sql @@ -0,0 +1,6 @@ +-- Create enum type for privacy status +CREATE TYPE privacy_status AS ENUM ('private', 'public'); + +-- Add privacy_status column to videos table with default value of 'public' +ALTER TABLE videos +ADD COLUMN privacy_status privacy_status NOT NULL DEFAULT 'public'; diff --git a/packages/db/src/videos.rs b/packages/db/src/videos.rs index 5ba650d..cc85d85 100644 --- a/packages/db/src/videos.rs +++ b/packages/db/src/videos.rs @@ -25,6 +25,7 @@ pub enum ProcessingStatus { } impl Video { + /// A function for creating new video data in the db pub async fn create( pool: &PgPool, user_id: Uuid, @@ -47,4 +48,62 @@ impl Video { .fetch_one(pool) .await } + /// A function for fetching video data from the db by video ID + pub async fn by_id(pool: &PgPool, video_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Video>( + r#" + SELECT id, user_id, title, raw_video_path, processed_video_path, + processing_status, created_at, updated_at + FROM videos + WHERE id = $1 + "#, + ) + .bind(video_id) + .fetch_optional(pool) + .await + } + /// A function for getting all user owned videos by user ID + pub async fn by_userid(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Video>( + r#" + SELECT id, user_id, title, raw_video_path, processed_video_path, + processing_status, created_at, updated_at + FROM videos + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await + } + /// A function for getting all user owned videos by user name + pub async fn by_username(pool: &PgPool, username: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Video>( + r#" + SELECT v.id, v.user_id, v.title, v.raw_video_path, v.processed_video_path, + v.processing_status, v.created_at, v.updated_at + FROM videos v + JOIN users u ON u.id = v.user_id + WHERE u.name = $1 + ORDER BY v.created_at DESC + "#, + ) + .bind(username) + .fetch_all(pool) + .await + } + /// A function for getting all videos + pub async fn all(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Video>( + r#" + SELECT id, user_id, title, raw_video_path, processed_video_path, + processing_status, created_at, updated_at + FROM videos + ORDER BY created_at DESC + "#, + ) + .fetch_all(pool) + .await + } } diff --git a/packages/queue/src/runner.rs b/packages/queue/src/runner.rs index 5015306..34fdc90 100644 --- a/packages/queue/src/runner.rs +++ b/packages/queue/src/runner.rs @@ -83,7 +83,6 @@ async fn handle_job(job: Job, db: &Pool) -> Result<(), Error> { Quality::new(1920, 1080, "5000k", "1080p"), Quality::new(1280, 720, "2800k", "720p"), Quality::new(854, 480, "1400k", "480p"), - Quality::new(640, 360, "800k", "360p"), ]; // Process the video diff --git a/packages/vod/Cargo.toml b/packages/vod/Cargo.toml index 07e59c1..c029368 100644 --- a/packages/vod/Cargo.toml +++ b/packages/vod/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] anyhow = "1.0" +tracing = "0.1" diff --git a/packages/vod/src/lib.rs b/packages/vod/src/lib.rs index dff1f34..d7fc862 100644 --- a/packages/vod/src/lib.rs +++ b/packages/vod/src/lib.rs @@ -1,6 +1,56 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; +use tracing::{debug, warn}; + +#[derive(Debug, Clone, PartialEq)] +pub enum VideoFormat { + MP4, + MOV, +} + +impl VideoFormat { + fn from_path(path: &Path) -> Result { + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()) + .ok_or_else(|| anyhow::anyhow!("Input file has no extension"))?; + + match extension.as_str() { + "mp4" => Ok(VideoFormat::MP4), + "mov" => Ok(VideoFormat::MOV), + _ => anyhow::bail!("Unsupported file format: {}", extension), + } + } + + fn get_ffmpeg_args(&self) -> Vec { + match self { + VideoFormat::MP4 => vec!["-movflags".to_string(), "+faststart".to_string()], + VideoFormat::MOV => vec![ + "-movflags".to_string(), + "+faststart".to_string(), + "-strict".to_string(), + "experimental".to_string(), + ], + } + } + + fn get_hls_args(&self) -> Vec { + vec![ + "-f".to_string(), + "hls".to_string(), + "-hls_time".to_string(), + "6".to_string(), + "-hls_list_size".to_string(), + "0".to_string(), + "-hls_segment_type".to_string(), + "mpegts".to_string(), + "-hls_flags".to_string(), + "independent_segments+split_by_time".to_string(), + ] + } +} pub struct HLSConverter { ffmpeg_path: PathBuf, @@ -32,6 +82,92 @@ impl Quality { } impl HLSConverter { + fn get_video_dimensions(&self, input_path: &Path) -> Result<(u32, u32)> { + let output = Command::new(&self.ffmpeg_path) + .arg("-i") + .arg(input_path) + .output() + .context("Failed to execute FFmpeg command for video info")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + debug!("FFmpeg output:\n{}", stderr); + + // Parse the video dimensions from FFmpeg output + for line in stderr.lines() { + if line.contains("Stream") && line.contains("Video:") { + debug!("Found video stream line: {}", line); + + // Try different patterns + let dimensions = line + .split(',') + .find(|s| s.contains('x') && s.trim().chars().any(|c| c.is_digit(10))) + .or_else(|| { + // Alternative pattern: look for dimensions like "1920x1080" + line.split_whitespace() + .find(|s| s.contains('x') && s.chars().any(|c| c.is_digit(10))) + }); + + if let Some(dim_str) = dimensions { + debug!("Found dimension string: {}", dim_str); + + // Clean up the dimension string + let clean_dim = dim_str + .trim() + .split(|c: char| !c.is_digit(10) && c != 'x') + .collect::(); + + if let Some(x_pos) = clean_dim.find('x') { + if let (Ok(width), Ok(height)) = ( + clean_dim[..x_pos].parse::(), + clean_dim[x_pos + 1..].parse::(), + ) { + debug!("Parsed dimensions: {}x{}", width, height); + return Ok((width, height)); + } + } + } + } + } + + // If the above fails, try using ffprobe + let probe_output = Command::new(&self.ffmpeg_path.with_file_name("ffprobe")) + .arg("-v") + .arg("error") + .arg("-select_streams") + .arg("v:0") + .arg("-show_entries") + .arg("stream=width,height") + .arg("-of") + .arg("csv=p=0") + .arg(input_path) + .output() + .context("Failed to execute ffprobe command")?; + + if probe_output.status.success() { + let output = String::from_utf8_lossy(&probe_output.stdout); + let dims: Vec<&str> = output.trim().split(',').collect(); + if dims.len() == 2 { + if let (Ok(width), Ok(height)) = (dims[0].parse::(), dims[1].parse::()) { + debug!("Got dimensions from ffprobe: {}x{}", width, height); + return Ok((width, height)); + } + } + } + + anyhow::bail!("Could not determine video dimensions") + } + + fn verify_dimensions(&self, width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + anyhow::bail!("Invalid dimensions: {}x{}", width, height); + } + if width > 7680 || height > 4320 { + // 8K limit + anyhow::bail!("Dimensions too large: {}x{}", width, height); + } + Ok(()) + } + pub fn new>(ffmpeg_path: P, output_dir: P) -> Result { let ffmpeg = ffmpeg_path.as_ref().to_path_buf(); let out_dir = output_dir.as_ref().to_path_buf(); @@ -48,16 +184,47 @@ impl HLSConverter { }) } + fn validate_input_format(&self, input_path: &Path) -> Result { + VideoFormat::from_path(input_path) + } + pub fn convert_to_hls>( &self, input_path: P, - qualities: Vec, + mut qualities: Vec, ) -> Result<()> { let input_path = input_path.as_ref(); if !input_path.exists() { anyhow::bail!("Input file not found: {:?}", input_path); } + let format = self.validate_input_format(input_path)?; + + // Get original video dimensions + let (original_width, original_height) = self.get_video_dimensions(input_path)?; + self.verify_dimensions(original_width, original_height)?; + + // Filter out qualities higher than the original resolution + qualities.retain(|q| { + if q.width > original_width || q.height > original_height { + warn!( + "Skipping quality {}x{} as it exceeds original resolution {}x{}", + q.width, q.height, original_width, original_height + ); + false + } else { + true + } + }); + + if qualities.is_empty() { + anyhow::bail!( + "No valid quality levels for video with resolution {}x{}", + original_width, + original_height + ); + } + // Create variant playlist let mut master_playlist = String::from("#EXTM3U\n#EXT-X-VERSION:3\n"); @@ -84,8 +251,14 @@ impl HLSConverter { quality, &playlist_name, &segment_pattern, + &format, ) - .with_context(|| format!("Failed to convert quality: {}", quality.name))?; + .with_context(|| { + format!( + "Failed to convert quality: {} ({}x{})", + quality.name, quality.width, quality.height + ) + })?; } // Write master playlist @@ -102,14 +275,29 @@ impl HLSConverter { quality: &Quality, playlist_name: &str, segment_pattern: &str, + format: &VideoFormat, ) -> Result<()> { - let output = Command::new(&self.ffmpeg_path) - .arg("-i") - .arg(input_path) + let mut command = Command::new(&self.ffmpeg_path); + + command.arg("-i").arg(input_path); + + // Add format-specific arguments + for arg in format.get_ffmpeg_args() { + command.arg(arg); + } + + command + .arg("-vsync") + .arg("0") + // Video encoding settings .arg("-c:v") .arg("libx264") .arg("-c:a") .arg("aac") + // Force pixel format + .arg("-pix_fmt") + .arg("yuv420p") + // Bitrate settings .arg("-b:v") .arg(&quality.bitrate) .arg("-maxrate") @@ -119,30 +307,51 @@ impl HLSConverter { "{}k", quality.bitrate.replace("k", "").parse::()? * 2 )) + // Encoding presets .arg("-preset") .arg("faster") + .arg("-profile:v") + .arg("main") + .arg("-level") + .arg("3.1") .arg("-g") .arg("60") + .arg("-keyint_min") + .arg("60") .arg("-sc_threshold") .arg("0") + .arg("-force_key_frames") + .arg("expr:gte(t,n_forced*6)") + // Resolution .arg("-s") .arg(format!("{}x{}", quality.width, quality.height)) - .arg("-f") - .arg("hls") - .arg("-hls_time") - .arg("6") - .arg("-hls_list_size") - .arg("0") - .arg("-hls_segment_type") - .arg("mpegts") + // Audio settings + .arg("-ar") + .arg("48000") + .arg("-ac") + .arg("2") + .arg("-b:a") + .arg("128k"); + + // Add HLS-specific settings + for arg in format.get_hls_args() { + command.arg(arg); + } + + command .arg("-hls_segment_filename") .arg(self.output_dir.join(segment_pattern)) - .arg(self.output_dir.join(playlist_name)) + .arg(self.output_dir.join(playlist_name)); + + debug!("FFmpeg command: {:?}", command); + + let output = command .output() .context("Failed to execute FFmpeg command")?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); + debug!("FFmpeg error output: {}", error); anyhow::bail!("FFmpeg failed: {}", error); } @@ -166,25 +375,3 @@ impl HLSConverter { .to_string()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hls_conversion() -> Result<()> { - let converter = HLSConverter::new("/usr/bin/ffmpeg", "output")?; - - println!("FFmpeg version: {}", converter.verify_ffmpeg()?); - - let qualities = vec![ - Quality::new(1920, 1080, "5000k", "1080p"), - Quality::new(1280, 720, "2800k", "720p"), - Quality::new(854, 480, "1400k", "480p"), - Quality::new(640, 360, "800k", "360p"), - ]; - - converter.convert_to_hls("input.mp4", qualities)?; - Ok(()) - } -} diff --git a/services/barn-ui/package.json b/services/barn-ui/package.json index afff091..b577d1a 100644 --- a/services/barn-ui/package.json +++ b/services/barn-ui/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.12", - "filesize": "^10.1.6" + "filesize": "^10.1.6", + "hls.js": "^1.5.17" } } diff --git a/services/barn-ui/src/lib/components/Alert.svelte b/services/barn-ui/src/lib/components/Alert.svelte index a365cb4..a7d5c2b 100644 --- a/services/barn-ui/src/lib/components/Alert.svelte +++ b/services/barn-ui/src/lib/components/Alert.svelte @@ -1,36 +1,44 @@ -
+
{#if type === 'error'} + {:else if type === 'success'} + + {:else if type === 'warning'} + + {:else if type === 'info'} + {/if}
-

{message}

+

+ {message} +

diff --git a/services/barn-ui/src/lib/components/Header.svelte b/services/barn-ui/src/lib/components/Header.svelte index 3959360..0701121 100644 --- a/services/barn-ui/src/lib/components/Header.svelte +++ b/services/barn-ui/src/lib/components/Header.svelte @@ -9,11 +9,13 @@