use std::path::Path;
pub(crate) async fn check_ffmpeg_available() -> Result<(), String> {
for binary in ["ffmpeg", "ffprobe"] {
tokio::process::Command::new(binary)
.arg("-version")
.output()
.await
.map_err(|_| {
format!(
"'{binary}' was not found on PATH -- required for \
'pigeon job run transform' to transcode images"
)
})?;
}
Ok(())
}
async fn probe_dimensions(path: &Path) -> Option<(u32, u32)> {
let output = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let mut parts = text.trim().split(',');
let width = parts.next()?.parse::<u32>().ok()?;
let height = parts.next()?.parse::<u32>().ok()?;
Some((width, height))
}
pub(crate) async fn transcode_to_jpg(input: &Path, output: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(input)
.args([
"-frames:v",
"1",
"-q:v",
"1",
"-pix_fmt",
"yuvj444p",
"-f",
"mjpeg",
])
.arg(output)
.output()
.await
.map_err(|err| format!("failed to run ffmpeg: {err}"))?;
if !result.status.success() {
return Err(format!(
"ffmpeg failed to transcode {}: {}",
input.display(),
String::from_utf8_lossy(&result.stderr).trim()
));
}
if let (Some(before), Some(after)) = (
probe_dimensions(input).await,
probe_dimensions(output).await,
) && before != after
{
return Err(format!(
"transcoding {} changed dimensions from {before:?} to {after:?}; \
refusing an output that isn't full-size",
input.display()
));
}
Ok(())
}
pub(crate) fn copy_through(input: &Path, output: &Path) -> Result<(), String> {
std::fs::copy(input, output).map_err(|err| {
format!(
"failed to copy {} to {}: {err}",
input.display(),
output.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_through_is_byte_for_byte_identical() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("photo.jpeg");
let output = dir.path().join("photo.jpg");
let bytes: Vec<u8> = (0..255).collect();
std::fs::write(&input, &bytes).unwrap();
copy_through(&input, &output).unwrap();
assert_eq!(std::fs::read(&output).unwrap(), bytes);
}
#[test]
fn copy_through_errors_on_a_missing_input() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("does-not-exist.jpeg");
let output = dir.path().join("out.jpg");
assert!(copy_through(&input, &output).is_err());
}
async fn generate_test_png(path: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=1",
"-frames:v",
"1",
])
.arg(path)
.output()
.await
.map_err(|err| format!("failed to run ffmpeg: {err}"))?;
if !result.status.success() {
return Err(String::from_utf8_lossy(&result.stderr).to_string());
}
Ok(())
}
#[tokio::test]
async fn transcode_to_jpg_preserves_dimensions_for_a_real_png() {
if check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.png");
generate_test_png(&input).await.unwrap();
let output = dir.path().join("test.jpg");
transcode_to_jpg(&input, &output).await.unwrap();
assert!(output.exists());
assert_eq!(
probe_dimensions(&input).await,
probe_dimensions(&output).await
);
assert_eq!(probe_dimensions(&output).await, Some((64, 48)));
}
#[tokio::test]
async fn transcode_to_jpg_succeeds_when_the_output_path_ends_in_scratch() {
if check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.png");
generate_test_png(&input).await.unwrap();
let output = dir.path().join("test-abcdef0123456789.jpg.scratch");
transcode_to_jpg(&input, &output).await.unwrap();
assert!(output.exists());
}
#[tokio::test]
async fn transcode_to_jpg_fails_fast_on_an_unreadable_input() {
if check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("not-an-image.heic");
std::fs::write(&input, b"this is not a real heic file").unwrap();
let output = dir.path().join("out.jpg");
let result = transcode_to_jpg(&input, &output).await;
assert!(result.is_err());
}
}