use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use image::DynamicImage;
const THUMB_MAX_PX: u32 = 1024;
pub fn thumbnail(path: &Path) -> Option<DynamicImage> {
let out = temp_png_path();
let ok = run_ffmpegthumbnailer(path, &out) || run_ffmpeg(path, &out);
let img = if ok {
image::ImageReader::open(&out)
.ok()
.and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.decode().ok())
} else {
None
};
let _ = std::fs::remove_file(&out); img
}
fn run_ffmpegthumbnailer(path: &Path, out: &Path) -> bool {
let status = Command::new("ffmpegthumbnailer")
.arg("-i")
.arg(path)
.arg("-o")
.arg(out)
.arg("-s")
.arg(THUMB_MAX_PX.to_string())
.arg("-q")
.arg("8")
.arg("-c")
.arg("png")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
matches!(status, Ok(s) if s.success()) && out_is_nonempty(out)
}
fn run_ffmpeg(path: &Path, out: &Path) -> bool {
let vf = format!("thumbnail,scale='min({THUMB_MAX_PX},iw)':-2");
let status = Command::new("ffmpeg")
.arg("-y")
.arg("-loglevel")
.arg("error")
.arg("-i")
.arg(path)
.arg("-frames:v")
.arg("1")
.arg("-vf")
.arg(vf)
.arg(out)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
matches!(status, Ok(s) if s.success()) && out_is_nonempty(out)
}
fn out_is_nonempty(out: &Path) -> bool {
std::fs::metadata(out).map(|m| m.len() > 0).unwrap_or(false)
}
fn temp_png_path() -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("konoma-vthumb-{}-{}.png", std::process::id(), n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonexistent_or_nonvideo_returns_none() {
assert!(thumbnail(Path::new("/no/such/video.mp4")).is_none());
}
#[test]
fn extracts_correct_frame_when_ffmpeg_available() {
let has_ffmpeg = Command::new("ffmpeg")
.arg("-version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !has_ffmpeg {
eprintln!("skip: ffmpeg 不在");
return;
}
let vid = std::env::temp_dir().join("konoma-vthumb-test-green.mp4");
let _ = std::fs::remove_file(&vid);
let made = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i"])
.arg("color=c=green:s=64x64:d=1")
.arg(&vid)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(made, "テスト用動画の生成に失敗");
let img = thumbnail(&vid).expect("ffmpeg があればサムネイルが取れるはず");
assert!(img.width() > 0 && img.height() > 0, "サムネイル寸法が 0");
let rgba = img.to_rgba8();
let px = rgba.get_pixel(rgba.width() / 2, rgba.height() / 2);
let (r, g, b) = (px[0], px[1], px[2]);
assert!(
g > r && g > b && g > 60,
"中央が緑でない(抽出フレームが元動画と不一致?): rgb=({r},{g},{b})"
);
std::fs::remove_file(&vid).ok();
}
}