use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use md5::Digest;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ThumbnailSize {
Small = 128,
Normal = 256,
Large = 512,
Huge = 1024,
}
impl ThumbnailSize {
pub fn dir_name(&self) -> &str {
match self {
ThumbnailSize::Small => "normal",
ThumbnailSize::Normal => "large",
ThumbnailSize::Large => "x-large",
ThumbnailSize::Huge => "xx-large",
}
}
pub fn pixel_size(&self) -> u32 {
*self as u32
}
pub fn for_display(display_size: u32) -> Self {
match display_size {
0..=192 => ThumbnailSize::Small,
193..=384 => ThumbnailSize::Normal,
385..=768 => ThumbnailSize::Large,
_ => ThumbnailSize::Huge,
}
}
}
#[derive(Clone)]
pub struct Thumbnailer {
cache_base: PathBuf,
}
impl Thumbnailer {
pub fn new() -> Self {
let cache_base = if let Ok(dir) = std::env::var("SHOWREET_THUMBNAIL_DIR") {
PathBuf::from(dir)
} else if cfg!(target_os = "windows") {
let local = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| std::env::var("APPDATA").unwrap_or_default());
PathBuf::from(local).join("showreet").join("thumbnails")
} else if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join("Library").join("Caches").join("showreet").join("thumbnails")
} else {
std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".cache")
})
.join("thumbnails")
};
Self { cache_base }
}
pub fn get_thumbnail(
&self,
video_path: &Path,
display_size: u32,
) -> Option<PathBuf> {
let size = ThumbnailSize::for_display(display_size);
let md5 = file_uri_md5(video_path);
let sizes_to_try = match size {
ThumbnailSize::Huge => vec![
ThumbnailSize::Huge,
ThumbnailSize::Large,
ThumbnailSize::Normal,
ThumbnailSize::Small,
],
ThumbnailSize::Large => vec![
ThumbnailSize::Large,
ThumbnailSize::Normal,
ThumbnailSize::Small,
],
ThumbnailSize::Normal => vec![ThumbnailSize::Normal, ThumbnailSize::Small],
ThumbnailSize::Small => vec![ThumbnailSize::Small],
};
for s in &sizes_to_try {
let path = self.cache_path(&md5, s);
if path.exists() {
return Some(path);
}
}
self.generate(&md5, video_path, size)
}
fn cache_path(&self, md5: &str, size: &ThumbnailSize) -> PathBuf {
self.cache_base.join(size.dir_name()).join(format!("{}.png", md5))
}
fn generate(
&self,
md5: &str,
video_path: &Path,
size: ThumbnailSize,
) -> Option<PathBuf> {
let target_dir = self.cache_base.join(size.dir_name());
let _ = std::fs::create_dir_all(&target_dir);
let output_path = target_dir.join(format!("{}.png", md5));
let temp_path = target_dir.join(format!("{}.tmp.png", md5));
if try_ffmpegthumbnailer(video_path, &temp_path, size.pixel_size()) {
let _ = std::fs::rename(&temp_path, &output_path);
return Some(output_path);
}
if try_ffmpeg(video_path, &temp_path, size.pixel_size()) {
let _ = std::fs::rename(&temp_path, &output_path);
return Some(output_path);
}
if try_gstreamer(video_path, &temp_path, size.pixel_size()) {
let _ = std::fs::rename(&temp_path, &output_path);
return Some(output_path);
}
let _ = std::fs::remove_file(&temp_path);
None
}
}
fn try_ffmpegthumbnailer(video_path: &Path, output: &Path, size: u32) -> bool {
Command::new("ffmpegthumbnailer")
.args([
"-i",
video_path.to_str().unwrap_or(""),
"-o",
output.to_str().unwrap_or(""),
"-s",
&size.to_string(),
"-q",
"8",
"-m",
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn try_ffmpeg(video_path: &Path, output: &Path, size: u32) -> bool {
let exe_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
let ffmpeg = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(exe_name)))
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from(exe_name));
let height = size * 9 / 16;
let vf = format!(
"scale={}:{}:force_original_aspect_ratio=increase,crop={}:{}",
size, height, size, height
);
Command::new(&ffmpeg)
.args([
"-ss", "5",
"-i", video_path.to_str().unwrap_or(""),
"-vf", &vf,
"-vframes", "1",
"-q:v", "2",
"-y",
output.to_str().unwrap_or(""),
])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn try_gstreamer(video_path: &Path, output: &Path, size: u32) -> bool {
use gstreamer::prelude::*;
let _ = gstreamer::init();
let height = size * 9 / 16;
let uri = format!(
"file:///{}",
video_path.to_string_lossy().replace('\\', "/")
);
let pipeline_str = format!(
"playbin uri=\"{}\" flags=0x2 video-sink=\"videoscale ! videoconvert ! video/x-raw,format=RGB,width={},height={} ! pngenc ! filesink location=\\\"{}\\\"\"",
uri, size, height,
output.to_string_lossy().replace('\\', "\\\\"),
);
let pipeline = match gstreamer::parse::launch(&pipeline_str) {
Ok(p) => p,
Err(_) => return false,
};
let pipeline = match pipeline.downcast::<gstreamer::Pipeline>() {
Ok(p) => p,
Err(_) => return false,
};
let bus = match pipeline.bus() {
Some(b) => b,
None => return false,
};
if pipeline.set_state(gstreamer::State::Paused).is_err() { return false; }
let _ = pipeline.seek_simple(
gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT,
gstreamer::ClockTime::from_seconds(5),
);
if pipeline.set_state(gstreamer::State::Playing).is_err() { return false; }
let timeout = gstreamer::ClockTime::from_seconds(10);
let started = Instant::now();
loop {
let elapsed = gstreamer::ClockTime::from_nseconds(started.elapsed().as_nanos() as u64);
let remaining = if elapsed < timeout { timeout - elapsed } else { gstreamer::ClockTime::ZERO };
match bus.timed_pop(remaining) {
Some(msg) => {
use gstreamer::MessageView;
match msg.view() {
MessageView::Error(_) => { let _ = pipeline.set_state(gstreamer::State::Null); return false; }
MessageView::Eos(_) => break,
_ => {}
}
}
None => { let _ = pipeline.set_state(gstreamer::State::Null); return false; }
}
}
let _ = pipeline.set_state(gstreamer::State::Null);
output.exists()
}
fn file_uri_md5(path: &Path) -> String {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let uri = format!("file://{}", canonical.display());
let mut hasher = md5::Md5::new();
hasher.update(uri.as_bytes());
let digest = hasher.finalize();
format!("{:x}", digest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thumbnail_size_for_display() {
assert_eq!(ThumbnailSize::for_display(100).pixel_size(), 128);
assert_eq!(ThumbnailSize::for_display(200).pixel_size(), 256);
assert_eq!(ThumbnailSize::for_display(400).pixel_size(), 512);
assert_eq!(ThumbnailSize::for_display(800).pixel_size(), 1024);
}
}