//! FFmpeg PATH probes and the "install ffmpeg" banner for inline video posters. use crate::prompt_images::InlineMediaInfo; pub const FFMPEG_HINT_TEXT: &str = "Install ffmpeg to view inline"; /// Latches positives; re-probes negatives so mid-session install recovers posters. pub fn ffmpeg_available() -> bool { #[cfg(test)] if let Some(v) = TEST_FFMPEG_OVERRIDE.with(|c| c.get()) { return v; } use std::sync::atomic::{AtomicBool, Ordering}; static FOUND: AtomicBool = AtomicBool::new(false); if FOUND.load(Ordering::Relaxed) { return true; } let available = kigi_config::shell::is_command_available("ffmpeg"); if available { FOUND.store(true, Ordering::Relaxed); } available } #[cfg(test)] thread_local! { static TEST_FFMPEG_OVERRIDE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } #[cfg(test)] pub(crate) fn set_ffmpeg_available_for_test(available: bool) -> FfmpegTestGuard { TEST_FFMPEG_OVERRIDE.with(|c| c.set(Some(available))); FfmpegTestGuard } #[cfg(test)] pub(crate) struct FfmpegTestGuard; #[cfg(test)] impl Drop for FfmpegTestGuard { fn drop(&mut self) { TEST_FFMPEG_OVERRIDE.with(|c| c.set(None)); } } /// First on-PATH package manager wins; `None` → hint-only banner (no install line). fn ffmpeg_install_candidates() -> &'static [(&'static str, &'static str)] { if cfg!(target_os = "macos") { &[("brew", "! brew install ffmpeg")] } else if cfg!(target_os = "windows") { &[ ("winget", "! winget install ffmpeg"), ("choco", "! choco install ffmpeg"), ("scoop", "! scoop install ffmpeg"), ] } else { &[ ("apt", "! sudo apt install ffmpeg"), ("apt-get", "! sudo apt-get install ffmpeg"), ("dnf", "! sudo dnf install ffmpeg"), ("pacman", "! sudo pacman -S ffmpeg"), ("zypper", "! sudo zypper install ffmpeg"), ("apk", "! sudo apk add ffmpeg"), ] } } /// Latches positives; re-probes negatives so mid-session PM install recovers the cmd line. pub fn ffmpeg_install_cmd() -> Option<&'static str> { #[cfg(test)] if let Some(v) = TEST_FFMPEG_INSTALL_CMD_OVERRIDE.with(|c| c.get()) { return v; } use std::sync::OnceLock; static FOUND: OnceLock<&'static str> = OnceLock::new(); if let Some(cmd) = FOUND.get() { return Some(*cmd); } for (manager, cmd) in ffmpeg_install_candidates() { if kigi_config::shell::is_command_available(manager) { let _ = FOUND.set(*cmd); return Some(*cmd); } } None } #[cfg(test)] thread_local! { static TEST_FFMPEG_INSTALL_CMD_OVERRIDE: std::cell::Cell>> = const { std::cell::Cell::new(None) }; } #[cfg(test)] pub(crate) fn set_ffmpeg_install_cmd_for_test( cmd: Option<&'static str>, ) -> FfmpegInstallCmdTestGuard { TEST_FFMPEG_INSTALL_CMD_OVERRIDE.with(|c| c.set(Some(cmd))); FfmpegInstallCmdTestGuard } #[cfg(test)] pub(crate) struct FfmpegInstallCmdTestGuard; #[cfg(test)] impl Drop for FfmpegInstallCmdTestGuard { fn drop(&mut self) { TEST_FFMPEG_INSTALL_CMD_OVERRIDE.with(|c| c.set(None)); } } fn ffmpeg_hint_banner_rows() -> u16 { if ffmpeg_install_cmd().is_some() { 2 } else { 1 } } /// `(image_area, total)` rows for an inline-media preview. Shared by entry-height /// reservation and block placement so they cannot drift. pub fn inline_media_reserved_rows(info: &InlineMediaInfo, content_width: u16) -> (u16, u16) { use crate::terminal::image::fit_image_to_cells; if info.is_video && !ffmpeg_available() { let banner_rows = ffmpeg_hint_banner_rows(); return (banner_rows, banner_rows + 1); } let max_cols = content_width.saturating_sub(2); let max_rows = (content_width / 2).clamp(4, 20); let (_cols, rows) = fit_image_to_cells(info.width, info.height, max_cols, max_rows); (rows, rows + 3) } #[cfg(test)] mod tests { use super::*; #[test] fn ffmpeg_install_candidates_are_runnable_prompt_hints() { for (manager, cmd) in ffmpeg_install_candidates() { assert!(!manager.is_empty(), "empty manager name"); assert!(cmd.starts_with("! "), "not prompt-runnable: {cmd:?}"); assert!(cmd.contains("ffmpeg"), "missing package: {cmd:?}"); } } #[test] fn ffmpeg_install_cmd_override_none_omits_command() { let _no_pm = set_ffmpeg_install_cmd_for_test(None); assert_eq!(ffmpeg_install_cmd(), None); } #[test] fn ffmpeg_install_cmd_override_some_returns_command() { let _pm = set_ffmpeg_install_cmd_for_test(Some("! brew install ffmpeg")); assert_eq!(ffmpeg_install_cmd(), Some("! brew install ffmpeg")); } fn media(is_video: bool) -> InlineMediaInfo { InlineMediaInfo { path: std::path::PathBuf::from("/tmp/x"), width: 640, height: 480, is_video, alt_text: String::new(), } } #[test] fn reserved_rows_image_is_full_poster_plus_button() { let _no_ffmpeg = set_ffmpeg_available_for_test(false); let (image_rows, total_rows) = inline_media_reserved_rows(&media(false), 80); assert!( (4..=20).contains(&image_rows), "poster budget: {image_rows}" ); assert_eq!(total_rows, image_rows + 3); } #[test] fn reserved_rows_video_shrinks_to_two_line_banner_with_install_cmd() { let _no_ffmpeg = set_ffmpeg_available_for_test(false); let _cmd = set_ffmpeg_install_cmd_for_test(Some("! brew install ffmpeg")); assert_eq!(inline_media_reserved_rows(&media(true), 80), (2, 3)); } #[test] fn reserved_rows_video_shrinks_to_one_line_banner_without_install_cmd() { let _no_ffmpeg = set_ffmpeg_available_for_test(false); let _no_pm = set_ffmpeg_install_cmd_for_test(None); assert_eq!(inline_media_reserved_rows(&media(true), 80), (1, 2)); } #[test] fn reserved_rows_video_is_full_poster_with_ffmpeg() { let _ffmpeg = set_ffmpeg_available_for_test(true); let (image_rows, total_rows) = inline_media_reserved_rows(&media(true), 80); assert!( (4..=20).contains(&image_rows), "poster budget: {image_rows}" ); assert_eq!(total_rows, image_rows + 3); } }