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_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_gstreamer(video_path: &Path, output: &Path, size: u32) -> bool {
use gstreamer::prelude::*;
let _ = gstreamer::init();
let height = size * 9 / 16;
let pipeline_str = format!(
"playbin uri=\"file://{}\" flags=0x2 video-sink=\"videoscale ! videoconvert ! video/x-raw,format=RGB,width={},height={} ! pngenc ! filesink location=\\\"{}\\\"\"",
video_path.to_string_lossy(),
size, height,
output.to_string_lossy(),
);
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;
}
if pipeline.seek_simple(
gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::KEY_UNIT,
gstreamer::ClockTime::from_seconds(5),
).is_err() {
}
if pipeline.set_state(gstreamer::State::Playing).is_err() {
return false;
}
let timeout = gstreamer::ClockTime::from_seconds(8);
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);
}
}