diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a68552..806aa71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: with: submodules: recursive + - name: Install system dependencies (macOS only) + run: brew install ffmpeg + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -98,7 +101,30 @@ jobs: - name: Install system dependencies (Linux only) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev + run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev pkg-config + + - name: Install system dependencies (macOS only) + if: runner.os == 'macOS' + run: brew install ffmpeg + + - name: Cache vcpkg (Windows only) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: C:\vcpkg + key: ${{ runner.os }}-vcpkg-ffmpeg-v3 + restore-keys: | + ${{ runner.os }}-vcpkg-ffmpeg- + + - name: Install system dependencies (Windows only) + if: runner.os == 'Windows' + run: | + if (-not (Test-Path "C:\vcpkg")) { + git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg + C:\vcpkg\bootstrap-vcpkg.bat + } + C:\vcpkg\vcpkg.exe install ffmpeg:x64-windows + echo "VCPKG_ROOT=C:\vcpkg" >> $env:GITHUB_ENV - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -108,12 +134,16 @@ jobs: - name: Run tests (Linux) if: runner.os == 'Linux' + env: + PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig run: mold -run cargo test - name: Run tests (macOS) if: runner.os == 'macOS' run: cargo test - name: Run tests (Windows) if: runner.os == 'Windows' + env: + VCPKG_ROOT: C:\vcpkg # disable terminal on windows run: cargo test --no-default-features @@ -131,12 +161,14 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install system dependencies (Linux only) - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev + run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev pkg-config - name: Cache dependencies uses: Swatinem/rust-cache@v2 - name: Build release binary + env: + PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig run: | mold -run cargo build --release cd target/release @@ -168,6 +200,9 @@ jobs: with: submodules: recursive + - name: Install system dependencies (macOS only) + run: brew install ffmpeg + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -263,6 +298,24 @@ jobs: with: submodules: recursive + - name: Cache vcpkg (Windows only) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: C:\vcpkg + key: ${{ runner.os }}-vcpkg-ffmpeg-v3 + restore-keys: | + ${{ runner.os }}-vcpkg-ffmpeg- + + - name: Install system dependencies (Windows only) + run: | + if (-not (Test-Path "C:\vcpkg")) { + git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg + C:\vcpkg\bootstrap-vcpkg.bat + } + C:\vcpkg\vcpkg.exe install ffmpeg:x64-windows + echo "VCPKG_ROOT=C:\vcpkg" >> $env:GITHUB_ENV + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -270,6 +323,8 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build release binary + env: + VCPKG_ROOT: C:\vcpkg run: | cargo build --no-default-features --release cd target\release diff --git a/Cargo.lock b/Cargo.lock index d491087..b403442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.101", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -873,6 +891,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -961,6 +988,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.38" @@ -1959,6 +1997,31 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ffmpeg-next" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da02698288e0275e442a47fc12ca26d50daf0d48b15398ba5906f20ac2e2a9f9" +dependencies = [ + "bitflags 2.9.1", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e9c75ebd4463de9d8998fb134ba26347fe5faee62fabf0a4b4d41bd500b4ad" +dependencies = [ + "bindgen", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + [[package]] name = "file_type" version = "0.8.6" @@ -2311,6 +2374,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "globalcache" version = "0.2.4" @@ -3218,6 +3287,7 @@ dependencies = [ "egui_kittest", "egui_term", "epub", + "ffmpeg-next", "file_type", "flate2", "font-kit", @@ -3843,6 +3913,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.1", + "libc", +] + [[package]] name = "num_enum" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index 53f6832..606ec2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,9 @@ bzip2 = "0.6" image = { version = "0" } kamadak-exif = "0" +# video handling +ffmpeg-next = "7.1.0" + # for pdf rendering pdf_render = { git = "https://github.com/houqp/pdf_render.git", rev = "00b907936e45d904f958197fa6039320d0ac098d", features = [ "embed", diff --git a/src/models/preview_content.rs b/src/models/preview_content.rs index ad0ce88..5e29911 100644 --- a/src/models/preview_content.rs +++ b/src/models/preview_content.rs @@ -85,6 +85,34 @@ impl std::fmt::Debug for ImageMeta { } } +/// Metadata for video files +#[derive(Clone)] +pub struct VideoMeta { + /// Video title (usually filename) + pub title: String, + /// Video metadata (key-value pairs) + pub metadata: HashMap, + /// Video thumbnail image + pub thumbnail: egui::widgets::ImageSource<'static>, + /// Keep the texture handle alive to prevent GPU texture from being freed + pub _texture_handle: Option, +} + +// Manual implementation of Debug for VideoMeta since TextureHandle doesn't implement Debug +impl std::fmt::Debug for VideoMeta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VideoMeta") + .field("title", &self.title) + .field("metadata", &self.metadata) + .field("thumbnail", &"ImageSource") + .field( + "_texture_handle", + &self._texture_handle.as_ref().map(|_| "TextureHandle"), + ) + .finish() + } +} + /// Represents different types of preview content that can be displayed in the right panel #[derive(Clone, Debug)] pub enum PreviewContent { @@ -97,6 +125,8 @@ pub enum PreviewContent { }, /// Image content with metadata Image(ImageMeta), + /// Video content with metadata and thumbnail + Video(VideoMeta), /// Zip file content with a list of entries Zip(Vec), /// Tar file content with a list of entries (supports both compressed and uncompressed) @@ -182,6 +212,20 @@ impl PreviewContent { }) } + /// Creates a new video preview content with a texture handle for thumbnail + pub fn video( + title: impl Into, + metadata: HashMap, + texture: egui::TextureHandle, + ) -> Self { + Self::Video(VideoMeta { + title: title.into(), + metadata, + thumbnail: egui::widgets::ImageSource::from(&texture), + _texture_handle: Some(texture), + }) + } + /// Creates a new zip preview content from a list of entries #[must_use] pub const fn zip(entries: Vec) -> Self { diff --git a/src/ui/popup/preview/mod.rs b/src/ui/popup/preview/mod.rs index 6736f95..a030902 100644 --- a/src/ui/popup/preview/mod.rs +++ b/src/ui/popup/preview/mod.rs @@ -9,6 +9,7 @@ use egui::Context; pub mod doc; pub mod image; +pub mod video; /// Handle the `ShowFilePreview` shortcut action /// This function was extracted from input.rs to reduce complexity @@ -97,6 +98,10 @@ pub fn handle_show_file_preview(app: &mut Kiorg, _ctx: &egui::Context) { // Show preview popup for image files app.show_popup = Some(PopupType::Preview); } + crate::ui::preview::video_extensions!() => { + // Show preview popup for video files + app.show_popup = Some(PopupType::Preview); + } v => { if let Some(syntax) = crate::ui::preview::text::find_syntax_from_path(path) { match crate::ui::preview::text::load_full_text(path, Some(syntax.name.as_str())) { @@ -186,6 +191,16 @@ pub fn show_preview_popup(ctx: &Context, app: &mut Kiorg) { available_height, ); } + PreviewContent::Video(video_meta) => { + // Use specialized popup video renderer + video::render_popup( + ui, + video_meta, + &app.colors, + available_width, + available_height, + ); + } PreviewContent::Pdf(pdf_meta) => { // Use specialized PDF popup renderer with navigation if let Some(path) = &selected_path { diff --git a/src/ui/popup/preview/video.rs b/src/ui/popup/preview/video.rs new file mode 100644 index 0000000..8757291 --- /dev/null +++ b/src/ui/popup/preview/video.rs @@ -0,0 +1,54 @@ +//! Video preview module for popup display + +use crate::config::colors::AppColors; +use crate::models::preview_content::VideoMeta; +use egui::{Image, RichText}; + +/// Render video content optimized for popup view +/// +/// This version focuses on displaying the video thumbnail at a large size +pub fn render_popup( + ui: &mut egui::Ui, + video_meta: &VideoMeta, + colors: &AppColors, + available_width: f32, + available_height: f32, +) { + // Use a layout that maximizes thumbnail space + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Use most available space for the thumbnail + let max_height = available_height * 0.90; + let max_width = available_width * 0.90; + + // Add the video thumbnail with maximum possible size + ui.add( + Image::new(video_meta.thumbnail.clone()) + .max_size(egui::vec2(max_width, max_height)) + .maintain_aspect_ratio(true), + ); + + ui.add_space(10.0); + + // Show duration if available + if let Some(duration) = video_meta.metadata.get("Duration") { + ui.label( + RichText::new(format!("Duration: {duration}")) + .color(colors.fg) + .size(14.0), + ); + } + + // Show dimensions if available + if let Some(dimensions) = video_meta.metadata.get("Dimensions") { + ui.label( + RichText::new(format!("Resolution: {dimensions}")) + .color(colors.fg_light) + .size(12.0), + ); + } + + ui.add_space(5.0); + }); +} diff --git a/src/ui/preview/mod.rs b/src/ui/preview/mod.rs index c7e9e6e..cdc47d2 100644 --- a/src/ui/preview/mod.rs +++ b/src/ui/preview/mod.rs @@ -6,6 +6,7 @@ pub mod image; pub mod loading; pub mod tar; pub mod text; +pub mod video; pub mod zip; use crate::app::Kiorg; @@ -61,6 +62,13 @@ macro_rules! image_extensions { }; } +#[macro_export] +macro_rules! video_extensions { + () => { + "mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "wmv" | "mpg" | "flv" + }; +} + #[macro_export] macro_rules! zip_extensions { () => { @@ -94,6 +102,7 @@ pub use epub_extensions; pub use image_extensions; pub use pdf_extensions; pub use tar_extensions; +pub use video_extensions; pub use zip_extensions; #[inline] @@ -147,6 +156,12 @@ pub fn update_cache(app: &mut Kiorg, ctx: &egui::Context) { image::read_image_with_metadata(&path, &ctx_clone) }); } + video_extensions!() => { + let ctx_clone = ctx.clone(); + loading::load_preview_async(app, entry.path, move |path| { + video::read_video_with_metadata(&path, &ctx_clone) + }); + } zip_extensions!() => { loading::load_preview_async(app, entry.path, |path| { let result = zip::read_zip_entries(&path); diff --git a/src/ui/preview/video.rs b/src/ui/preview/video.rs new file mode 100644 index 0000000..3129192 --- /dev/null +++ b/src/ui/preview/video.rs @@ -0,0 +1,616 @@ +//! Video preview module + +use crate::config::colors::AppColors; +use crate::models::preview_content::{PreviewContent, VideoMeta}; +use egui::{Image, RichText}; +use ffmpeg_next::{ + codec::context::Context as CodecContext, + format, init, + log::Level, + media::Type, + packet::side_data::Type as SideDataType, + software::scaling::{context::Context as ScalerContext, flag::Flags}, + util::{ + format::pixel::Pixel, + frame::video::Video, + mathematics::{Rescale, rescale}, + }, +}; +use std::collections::HashMap; +use std::path::Path; + +const METADATA_KEY_COLUMN_WIDTH: f32 = 100.0; + +/// Render video content +pub fn render( + ui: &mut egui::Ui, + video_meta: &VideoMeta, + colors: &AppColors, + available_width: f32, + available_height: f32, +) { + // Display video title + ui.label( + RichText::new(&video_meta.title) + .color(colors.fg) + .strong() + .size(20.0), + ); + ui.add_space(10.0); + + // Display video thumbnail (centered) + ui.vertical_centered(|ui| { + ui.add( + Image::new(video_meta.thumbnail.clone()) + .max_size(egui::vec2(available_width, available_height * 0.6)) + .maintain_aspect_ratio(true), + ); + }); + ui.add_space(15.0); + + // Create a table for video metadata + ui.label( + RichText::new("Video Metadata") + .color(colors.fg_folder) + .strong() + .size(14.0), + ); + ui.add_space(5.0); + + egui::Grid::new("video_metadata_grid") + .num_columns(2) + .spacing([10.0, 6.0]) + .striped(true) + .show(ui, |ui| { + // Sort keys for consistent display + let mut sorted_keys: Vec<&String> = video_meta.metadata.keys().collect(); + sorted_keys.sort(); + + // Display each metadata field in a table row + for key in sorted_keys { + if let Some(value) = video_meta.metadata.get(key) { + ui.with_layout(egui::Layout::left_to_right(egui::Align::LEFT), |ui| { + ui.set_min_width(METADATA_KEY_COLUMN_WIDTH); + ui.set_max_width(METADATA_KEY_COLUMN_WIDTH); + ui.add(egui::Label::new(RichText::new(key).color(colors.fg)).wrap()); + }); + ui.add(egui::Label::new(RichText::new(value).color(colors.fg)).wrap()); + ui.end_row(); + } + } + }); +} + +/// Read video file, extract metadata and generate thumbnail, and create a `PreviewContent` +pub fn read_video_with_metadata( + path: &Path, + ctx: &egui::Context, +) -> Result { + // Get the filename for the title + let title = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Create a HashMap to store metadata + let mut metadata = HashMap::new(); + + // Add file size + if let Ok(file_metadata) = std::fs::metadata(path) { + let size = file_metadata.len(); + metadata.insert( + "File Size".to_string(), + humansize::format_size(size, humansize::BINARY), + ); + } + + // Get file extension for file type information + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_uppercase(); + metadata.insert("File Type".to_string(), ext_str.to_string()); + } + + // Try to extract a real thumbnail from the video + let thumbnail_texture = match extract_video_thumbnail(ctx, path, &mut metadata) { + Ok(texture) => texture, + Err(_e) => { + // Fall back to placeholder thumbnail + generate_placeholder_thumbnail(ctx, path) + .map_err(|e| format!("Failed to generate thumbnail: {e}"))? + } + }; + + Ok(PreviewContent::video(title, metadata, thumbnail_texture)) +} + +/// Extract a thumbnail from the video file using ffmpeg-next with quality scoring +fn extract_video_thumbnail( + ctx: &egui::Context, + path: &Path, + metadata: &mut HashMap, +) -> Result { + // Initialize ffmpeg + init().map_err(|e| format!("Failed to initialize ffmpeg: {e}"))?; + ffmpeg_next::log::set_level(Level::Quiet); + + let path_str = path.to_str().ok_or("Invalid path encoding")?; + let mut ictx = format::input(path_str).map_err(|e| format!("Failed to open input: {e}"))?; + let stream = ictx + .streams() + .best(Type::Video) + .ok_or("No video stream found")?; + let video_stream_index = stream.index(); + let video_params = stream.parameters(); + + let mut decoder = CodecContext::from_parameters(video_params) + .map_err(|e| format!("Failed to create decoder context: {e}"))? + .decoder() + .video() + .map_err(|e| format!("Failed to create video decoder: {e}"))?; + + // Get video dimensions and add to metadata + let width = decoder.width(); + let height = decoder.height(); + + // Check for rotation metadata + let rotation = extract_rotation_from_stream(&stream); + + // Check for the pixel aspect ratio + let par = decoder.aspect_ratio(); + let has_par = par.0 != 0 && par.1 != 0 && !(par.0 == 1 && par.1 == 1); + + let (output_width, output_height) = if has_par { + // Calculate display dimensions if pixel aspect ratio is present + let display_width = (decoder.width() as f64 * par.0 as f64 / par.1 as f64) as u32; + (display_width, decoder.height()) + } else { + (decoder.width(), decoder.height()) + }; + + metadata.insert("Dimensions".to_string(), format!("{width}x{height}")); + if has_par { + metadata.insert( + "Display Dimensions".to_string(), + format!("{output_width}x{output_height}"), + ); + metadata.insert( + "Pixel Aspect Ratio".to_string(), + format!("{}:{}", par.0, par.1), + ); + } + + // Get duration of video and its time base + let duration = stream.duration(); + let time_base = stream.time_base(); + + // Calculate the duration of the video in seconds + let duration_seconds = duration as f64 * time_base.0 as f64 / time_base.1 as f64; + + // Add duration to metadata + let total_seconds = duration_seconds as u64; + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + metadata.insert( + "Duration".to_string(), + format!("{hours}:{minutes:02}:{seconds:02}"), + ); + } else { + metadata.insert("Duration".to_string(), format!("{minutes}:{seconds:02}")); + } + + // Create a scaler to convert to RGB24 format and handle pixel aspect ratio + let mut scaler = ScalerContext::get( + decoder.format(), + decoder.width(), + decoder.height(), + Pixel::RGB24, + output_width, + output_height, + Flags::BILINEAR, + ) + .map_err(|e| format!("Failed to create scaler: {e}"))?; + + // Sample from 0%, 25%, 50%, 75% of the video + let seek_positions = [0.0, 0.25, 0.5, 0.75]; + let mut frames = Vec::new(); + let mut frame_scores = Vec::new(); + + for &seek_ratio in &seek_positions { + // Convert seek position to seconds, then rescale to FFmpeg's base timebase + let target_seconds = (duration_seconds * seek_ratio) as i64; + let target_timestamp = target_seconds.rescale((1, 1), rescale::TIME_BASE); + + // Seek to the target timestamp + if ictx.seek(target_timestamp, ..target_timestamp).is_err() { + continue; + } + + // Flush the decoder after seeking + decoder.flush(); + + // Read a few packets after the seek to get a frame + let mut packets_processed = 0; + let max_packets = 20; + + for (stream, packet) in ictx.packets() { + if stream.index() != video_stream_index { + continue; + } + + packets_processed += 1; + if packets_processed > max_packets { + break; + } + + if decoder.send_packet(&packet).is_err() { + continue; + } + + let mut frame = Video::empty(); + if decoder.receive_frame(&mut frame).is_ok() { + // Convert the frame to RGB24 format + let mut rgb_frame = Video::empty(); + if scaler.run(&frame, &mut rgb_frame).is_err() { + continue; + } + + // Grab raw pixel data and properties + let data = rgb_frame.data(0); + let linesize = rgb_frame.stride(0); + let frame_height = output_height as usize; + let frame_width = output_width as usize; + + // Extract RGB pixels + let mut rgb_pixels = Vec::with_capacity(frame_width * frame_height * 3); + + for y in 0..frame_height { + for x in 0..frame_width { + let src_offset = y * linesize + x * 3; + if src_offset + 2 < data.len() { + rgb_pixels.push(data[src_offset]); + rgb_pixels.push(data[src_offset + 1]); + rgb_pixels.push(data[src_offset + 2]); + } + } + } + + frames.push((frame_width, frame_height, rgb_pixels.clone())); + + // Calculate quality score for this frame using RGB data + let rgb_tuples: Vec<(u8, u8, u8)> = rgb_pixels + .chunks_exact(3) + .map(|chunk| (chunk[0], chunk[1], chunk[2])) + .collect(); + + let quality_score = + calculate_frame_quality(&rgb_tuples, frame_width as u32, frame_height as u32); + + frame_scores.push(quality_score); + + break; + } + } + } + + if frames.is_empty() { + return Err("No frames could be extracted".to_string()); + } + + // Find the best frame based on quality scores + let best_frame_index = frame_scores + .iter() + .enumerate() + .max_by(|(_, score_a), (_, score_b)| score_a.partial_cmp(score_b).unwrap()) + .map(|(index, _)| index) + .unwrap_or(0); + + let (frame_width, frame_height, rgb_data) = &frames[best_frame_index]; + + // Apply rotation if needed + let (final_width, final_height, final_rgb_data) = if rotation != 0 { + apply_rotation(rgb_data, *frame_width, *frame_height, rotation) + } else { + (*frame_width, *frame_height, rgb_data.clone()) + }; + + // Create egui image from RGB data + let color_image = egui::ColorImage::from_rgb([final_width, final_height], &final_rgb_data); + + // Create the texture with path-based ID for caching + let texture_id = format!("video_thumbnail_{}", path.display()); + let texture = ctx.load_texture(texture_id, color_image, egui::TextureOptions::default()); + + Ok(texture) +} + +/// Generate a placeholder thumbnail for video files if extraction fails +fn generate_placeholder_thumbnail( + ctx: &egui::Context, + path: &Path, +) -> Result { + let width = 320; + let height = 240; + + let mut rgb_data = Vec::with_capacity(width * height * 3); + + // Create a dark background with a play button symbol + for y in 0..height { + for x in 0..width { + // Create a dark gray background + let mut r = 40u8; + let mut g = 40u8; + let mut b = 40u8; + + // Add a border + if x < 2 || x >= width - 2 || y < 2 || y >= height - 2 { + r = 80; + g = 80; + b = 80; + } + + // Add a triangular play button in the center + let center_x = width / 2; + let center_y = height / 2; + let rel_x = x as i32 - center_x as i32; + let rel_y = y as i32 - center_y as i32; + + if (-15..=15).contains(&rel_x) && rel_y.abs() <= 15 { + let max_y = if rel_x <= 0 { + 15 + } else { + 15 - (rel_x * 15) / 15 + }; + + if rel_y.abs() <= max_y { + r = 220; + g = 220; + b = 220; + } + } + + rgb_data.push(r); + rgb_data.push(g); + rgb_data.push(b); + } + } + + // Create the texture + let color_image = egui::ColorImage::from_rgb([width, height], &rgb_data); + let texture_id = format!("video_placeholder_{}", path.display()); + let texture = ctx.load_texture(texture_id, color_image, egui::TextureOptions::default()); + + Ok(texture) +} + +/// Calculate frame quality score based on brightness, variance, and sharpness +/// +/// This function performs three calculations: +/// 1. Brightness score based on average luminance +/// 2. Variance score based on brightness variance +/// 3. Sharpness score based on Laplacian variance +/// +/// It then returns a weighted score between 0.0 and 1.0 +/// +fn calculate_frame_quality(rgb_data: &[(u8, u8, u8)], width: u32, height: u32) -> f64 { + let pixel_count = width * height; + + let mut total_brightness = 0.0; + let mut brightness_values = Vec::new(); + + for &(r, g, b) in rgb_data { + let r = r as f64; + let g = g as f64; + let b = b as f64; + + // Calculate luminance using Photometric/digital ITU BT.709 + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + total_brightness += luminance; + brightness_values.push(luminance); + } + + let average_brightness = total_brightness / pixel_count as f64; + + let brightness_score = if average_brightness < 22.0 { + // Unsuitable too dark: MGV < 22 + 0.0 + } else if average_brightness < 56.0 { + // Underexposed: MGV 22-55 + (average_brightness - 22.0) / (56.0 - 22.0) + } else if average_brightness <= 171.0 { + // Suitable brightness: MGV 56-171 + 1.0 + } else if average_brightness <= 194.0 { + // Overexposed: MGV 172-194 + 1.0 - (average_brightness - 171.0) / (194.0 - 171.0) + } else { + // Unsuitable too bright: MGV > 194 + 0.0 + }; + + // Calculate variance in brightness + let variance = brightness_values + .iter() + .map(|&v| (v - average_brightness).powi(2)) + .sum::() + / pixel_count as f64; + + let variance_score = (variance / 5000.0).min(1.0); + + // Calculate sharpness using Laplacian variance method + let mut laplacian_sum = 0.0; + let mut laplacian_count = 0; + + for y in 1..(height - 1) { + for x in 1..(width - 1) { + let center_idx = (y * width + x) as usize; + if center_idx < brightness_values.len() { + let center = brightness_values[center_idx]; + let top = brightness_values[((y - 1) * width + x) as usize]; + let bottom = brightness_values[((y + 1) * width + x) as usize]; + let left = brightness_values[(y * width + x - 1) as usize]; + let right = brightness_values[(y * width + x + 1) as usize]; + + let laplacian = -top - bottom - left - right + 4.0 * center; + laplacian_sum += laplacian * laplacian; + laplacian_count += 1; + } + } + } + + let laplacian_variance = if laplacian_count > 0 { + laplacian_sum / laplacian_count as f64 + } else { + 0.0 + }; + + // Normalize sharpness score + let sharpness_score = (laplacian_variance / 2000.0).min(1.0); + + // Combined score (weighted average) + brightness_score * 0.33 + variance_score * 0.33 + sharpness_score * 0.33 +} + +fn extract_rotation_from_stream(stream: &ffmpeg_next::Stream) -> i32 { + // Parse display matrix from side data + let side_data = stream.side_data(); + for data in side_data { + if data.kind() == SideDataType::DisplayMatrix { + let matrix_data = data.data(); + + if matrix_data.len() < 36 { + continue; + } + + // Read first 8 bytes for a,b values from the transformation matrix + let a_bytes = &matrix_data[0..4]; + let b_bytes = &matrix_data[4..8]; + + // Video formats store matrix values as 16.16 fixed-point (65536 = 2^16) + let a = i32::from_be_bytes([a_bytes[0], a_bytes[1], a_bytes[2], a_bytes[3]]) as f64 + / 65536.0; + let b = i32::from_be_bytes([b_bytes[0], b_bytes[1], b_bytes[2], b_bytes[3]]) as f64 + / 65536.0; + + // Rotation matrix for angle θ: + // [cos(θ) -sin(θ)] = [a b] + // [sin(θ) cos(θ)] [c d] + let rotation_radians = (-b).atan2(a); + let rotation_degrees = rotation_radians.to_degrees(); + let normalized = ((rotation_degrees.round() as i32 % 360) + 360) % 360; + + // Snap to nearest 90-degree increment + return if normalized <= 45 || normalized >= 315 { + 0 + } else if normalized <= 135 { + 90 + } else if normalized <= 225 { + 180 + } else if normalized <= 314 { + 270 + } else { + 0 + }; + } + } + + 0 +} + +/// Apply rotation to RGB image data using matrix operations +/// Returns (new_width, new_height, rotated_rgb_data) +fn apply_rotation( + rgb_data: &[u8], + width: usize, + height: usize, + rotation: i32, +) -> (usize, usize, Vec) { + match rotation { + 90 => { + // 90° counter-clockwise: transpose + reverse columns + let (new_width, new_height, transposed) = transpose(rgb_data, width, height); + let rotated = reverse_columns(&transposed, new_width, new_height); + (new_width, new_height, rotated) + } + 180 => { + // 180°: reverse rows + reverse columns + let reversed_rows = reverse_rows(rgb_data, width, height); + let rotated = reverse_columns(&reversed_rows, width, height); + (width, height, rotated) + } + 270 => { + // 270° (90° clockwise): transpose + reverse rows + let (new_width, new_height, transposed) = transpose(rgb_data, width, height); + let rotated = reverse_rows(&transposed, new_width, new_height); + (new_width, new_height, rotated) + } + _ => (width, height, rgb_data.to_vec()), + } +} + +/// Transpose image matrix (swap rows and columns) +fn transpose(rgb_data: &[u8], width: usize, height: usize) -> (usize, usize, Vec) { + let new_width = height; + let new_height = width; + let mut transposed = vec![0u8; new_width * new_height * 3]; + + for y in 0..height { + for x in 0..width { + let src_idx = (y * width + x) * 3; + let dst_idx = (x * new_width + y) * 3; + + if src_idx + 2 < rgb_data.len() && dst_idx + 2 < transposed.len() { + transposed[dst_idx] = rgb_data[src_idx]; + transposed[dst_idx + 1] = rgb_data[src_idx + 1]; + transposed[dst_idx + 2] = rgb_data[src_idx + 2]; + } + } + } + + (new_width, new_height, transposed) +} + +/// Reverse rows (flip horizontally) +fn reverse_rows(rgb_data: &[u8], width: usize, height: usize) -> Vec { + let mut reversed = vec![0u8; width * height * 3]; + + for y in 0..height { + for x in 0..width { + let src_idx = (y * width + x) * 3; + let dst_x = width - 1 - x; + let dst_idx = (y * width + dst_x) * 3; + + if src_idx + 2 < rgb_data.len() && dst_idx + 2 < reversed.len() { + reversed[dst_idx] = rgb_data[src_idx]; + reversed[dst_idx + 1] = rgb_data[src_idx + 1]; + reversed[dst_idx + 2] = rgb_data[src_idx + 2]; + } + } + } + + reversed +} + +/// Reverse columns (flip vertically) +fn reverse_columns(rgb_data: &[u8], width: usize, height: usize) -> Vec { + let mut reversed = vec![0u8; width * height * 3]; + + for y in 0..height { + for x in 0..width { + let src_idx = (y * width + x) * 3; + let dst_y = height - 1 - y; + let dst_idx = (dst_y * width + x) * 3; + + if src_idx + 2 < rgb_data.len() && dst_idx + 2 < reversed.len() { + reversed[dst_idx] = rgb_data[src_idx]; + reversed[dst_idx + 1] = rgb_data[src_idx + 1]; + reversed[dst_idx + 2] = rgb_data[src_idx + 2]; + } + } + } + + reversed +} diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 34d23f7..61bb0ee 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -129,6 +129,15 @@ pub fn draw(app: &mut Kiorg, ctx: &egui::Context, ui: &mut Ui, width: f32, heigh available_height, ); } + Some(PreviewContent::Video(ref video_meta)) => { + preview::video::render( + ui, + video_meta, + colors, + available_width, + available_height, + ); + } Some(PreviewContent::Pdf(ref pdf_meta)) => { preview::doc::render( ui, diff --git a/tests/mod/ui_test_helpers.rs b/tests/mod/ui_test_helpers.rs index cb830ea..34d950b 100644 --- a/tests/mod/ui_test_helpers.rs +++ b/tests/mod/ui_test_helpers.rs @@ -473,3 +473,27 @@ pub fn ctrl_modifiers() -> egui::Modifiers { ..Default::default() } } + +/// Create a test video file with minimal valid MP4 content +pub fn create_test_video(path: &PathBuf) -> PathBuf { + // Create a minimal valid MP4 file header + // This is a very basic MP4 file structure that should be recognized as a video file + let mp4_data: &[u8] = &[ + // ftyp box (file type box) + 0x00, 0x00, 0x00, 0x20, // box size (32 bytes) + 0x66, 0x74, 0x79, 0x70, // 'ftyp' + 0x69, 0x73, 0x6F, 0x6D, // major brand 'isom' + 0x00, 0x00, 0x02, 0x00, // minor version + 0x69, 0x73, 0x6F, 0x6D, // compatible brand 'isom' + 0x69, 0x73, 0x6F, 0x32, // compatible brand 'iso2' + 0x61, 0x76, 0x63, 0x31, // compatible brand 'avc1' + 0x6D, 0x70, 0x34, 0x31, // compatible brand 'mp41' + // mdat box (media data box) - minimal empty media data + 0x00, 0x00, 0x00, 0x08, // box size (8 bytes) + 0x6D, 0x64, 0x61, 0x74, // 'mdat' + ]; + + std::fs::write(path, mp4_data).unwrap(); + assert!(path.exists()); + path.clone() +} diff --git a/tests/ui_preview_test.rs b/tests/ui_preview_test.rs index e85eaf7..5a58f85 100644 --- a/tests/ui_preview_test.rs +++ b/tests/ui_preview_test.rs @@ -5,7 +5,9 @@ use egui::Key; use kiorg::models::preview_content::PreviewContent; use tempfile::tempdir; use ui_test_helpers::{ - create_harness, create_test_image, create_test_tar, create_test_zip, wait_for_condition, + + create_harness, create_test_image, create_test_tar, create_test_video, create_test_zip, wait_for_condition, +, }; /// Test for text preview of regular text files @@ -189,6 +191,68 @@ fn test_image_preview() { } } +/// Test for video preview with metadata extraction +#[test] +fn test_video_file_preview() { + // Create a temporary directory for testing + let temp_dir = tempdir().unwrap(); + + // Create test video file + let video_path = temp_dir.path().join("test_video.mp4"); + create_test_video(&video_path); + + // Start the harness + let mut harness = create_harness(&temp_dir); + + // Navigate to the video file + harness.key_press(Key::J); + harness.step(); + + // Wait for video preview to load + for _ in 0..100 { + match harness.state().preview_content.as_ref() { + Some(PreviewContent::Video(_)) => break, // Video preview loaded + _ => { + std::thread::sleep(std::time::Duration::from_millis(10)); + harness.step(); // Continue stepping until the video preview loads + } + } + } + + // Check if the preview content is video + match &harness.state().preview_content { + Some(PreviewContent::Video(video_meta)) => { + // Check that basic metadata is present + assert!( + !video_meta.title.is_empty(), + "Video title should not be empty" + ); + assert!( + video_meta.title.contains("test_video.mp4"), + "Video title should contain filename" + ); + + // Check for expected metadata fields + assert!( + video_meta.metadata.contains_key("File Size"), + "Should have file size metadata" + ); + assert!( + video_meta.metadata.contains_key("File Type"), + "Should have file type metadata" + ); + + // Verify file type is correct + let file_type = video_meta.metadata.get("File Type").unwrap(); + assert_eq!(file_type, "MP4", "File type should be MP4"); + } + Some(other) => { + panic!("Preview content should be Video variant, got {other:?}"); + } + None => panic!("Preview content should not be None"), + } +} + /// Test for zip preview #[test] fn test_zip_preview() {