use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use image::DynamicImage;
const THUMB_MAX_PX: u32 = 1024;
const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
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 mut cmd = Command::new("ffmpegthumbnailer");
cmd.arg("-i")
.arg(path)
.arg("-o")
.arg(out)
.arg("-s")
.arg(THUMB_MAX_PX.to_string())
.arg("-q")
.arg("8")
.arg("-c")
.arg("png")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT), Some(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 mut cmd = Command::new("ffmpeg");
cmd.arg("-y")
.arg("-nostdin")
.arg("-loglevel")
.arg("error")
.arg("-i")
.arg(path)
.arg("-frames:v")
.arg("1")
.arg("-vf")
.arg(vf)
.arg(out)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT), Some(s) if s.success())
&& out_is_nonempty(out)
}
fn spawn_and_wait_with_timeout(
cmd: &mut Command,
timeout: Duration,
) -> Option<std::process::ExitStatus> {
let mut child: Child = cmd.spawn().ok()?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait(); return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => return None,
}
}
}
fn out_is_nonempty(out: &Path) -> bool {
std::fs::metadata(out).map(|m| m.len() > 0).unwrap_or(false)
}
fn private_temp_dir() -> PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("konoma-vthumb-{}", std::process::id()));
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let _ = std::fs::DirBuilder::new().mode(0o700).create(&dir);
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
{
let _ = std::fs::create_dir(&dir);
}
dir
})
.clone()
}
fn temp_png_path() -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
private_temp_dir().join(format!("thumb-{n}.png"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
static PATH_MUTATING_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn nonexistent_or_nonvideo_returns_none() {
assert!(
thumbnail(Path::new("/no/such/video.mp4")).is_none(),
"存在しないパスは None"
);
let dir = unique_tmp("konoma_video_nonvideo_test");
std::fs::create_dir_all(&dir).unwrap();
let not_a_video = dir.join("notes.mp4");
std::fs::write(¬_a_video, b"this is plain text, not an mp4 container\n").unwrap();
assert!(
thumbnail(¬_a_video).is_none(),
".mp4 という名前だけの非動画ファイルは None(ffmpeg があれば実際に起動して拒否したことを検査する)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn extracts_correct_frame_when_ffmpeg_available() {
let _guard = PATH_MUTATING_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let has_ffmpeg = Command::new("ffmpeg")
.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !has_ffmpeg {
eprintln!("skip: ffmpeg 不在");
return;
}
let vid = unique_tmp("konoma-vthumb-test-green").with_extension("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)
.stdin(Stdio::null())
.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();
}
#[cfg(unix)]
#[test]
fn ffmpeg_tools_never_inherit_this_process_stdin() {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::io::AsRawFd;
extern "C" {
fn dup(fd: i32) -> i32;
fn dup2(oldfd: i32, newfd: i32) -> i32;
fn close(fd: i32) -> i32;
}
let _guard = PATH_MUTATING_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = unique_tmp("konoma_vthumb_stdin_leak_test");
std::fs::create_dir_all(&dir).expect("create test dir");
let sentinel_path = dir.join("sentinel.txt");
std::fs::write(&sentinel_path, b"STDIN_LEAK_SENTINEL\n").unwrap();
for name in ["ffmpeg", "ffmpegthumbnailer"] {
let script = dir.join(name);
std::fs::write(
&script,
format!("#!/bin/sh\ncat > \"$(dirname \"$0\")/captured_{name}.txt\"\nexit 1\n"),
)
.unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
}
let orig_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", dir.display(), orig_path);
unsafe { std::env::set_var("PATH", &new_path) };
let sentinel_file = std::fs::File::open(&sentinel_path).expect("open sentinel");
let saved_stdin = unsafe { dup(0) };
assert!(saved_stdin >= 0, "failed to save this process's fd 0");
let rc = unsafe { dup2(sentinel_file.as_raw_fd(), 0) };
assert_eq!(rc, 0, "failed to redirect fd 0 to the sentinel file");
struct Restore {
orig_path: String,
saved_stdin: i32,
}
impl Drop for Restore {
fn drop(&mut self) {
unsafe {
dup2(self.saved_stdin, 0);
close(self.saved_stdin);
std::env::set_var("PATH", &self.orig_path);
}
}
}
let _restore = Restore {
orig_path: orig_path.clone(),
saved_stdin,
};
let out = dir.join("out.png");
let _ = run_ffmpegthumbnailer(Path::new("/dev/null"), &out);
let _ = run_ffmpeg(Path::new("/dev/null"), &out);
let captured_thumbnailer =
std::fs::read(dir.join("captured_ffmpegthumbnailer.txt")).unwrap_or_default();
let captured_ffmpeg = std::fs::read(dir.join("captured_ffmpeg.txt")).unwrap_or_default();
assert!(
captured_thumbnailer.is_empty(),
"run_ffmpegthumbnailer が親プロセスの stdin を子に継承している(sentinel を読めてしまった): {:?}",
String::from_utf8_lossy(&captured_thumbnailer)
);
assert!(
captured_ffmpeg.is_empty(),
"run_ffmpeg が親プロセスの stdin を子に継承している(sentinel を読めてしまった): {:?}",
String::from_utf8_lossy(&captured_ffmpeg)
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn temp_png_path_lives_in_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt;
let path = temp_png_path();
let dir = path.parent().expect("temp_png_path has a parent dir");
assert_ne!(
dir,
std::env::temp_dir(),
"システム共有の一時ディレクトリ直下に出力している(専用サブディレクトリを持っていない)"
);
let meta = std::fs::metadata(dir).expect("private temp dir should exist by now");
assert!(meta.is_dir());
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"抽出フレームの置き場が owner-only(0700) になっていない: {dir:?} mode={mode:#o}"
);
}
#[cfg(unix)]
#[test]
fn spawn_and_wait_with_timeout_kills_a_command_that_never_exits() {
let mut cmd = Command::new("tail");
cmd.arg("-f")
.arg("/dev/null")
.stdout(Stdio::null())
.stderr(Stdio::null());
let started = Instant::now();
let status = spawn_and_wait_with_timeout(&mut cmd, Duration::from_millis(200));
let elapsed = started.elapsed();
assert!(
status.is_none(),
"ハングするコマンドが None(タイムアウト)を返さなかった: {status:?}"
);
assert!(
elapsed < Duration::from_secs(10),
"タイムアウトが機能せずブロックし続けた: elapsed={elapsed:?}"
);
}
}