use std::path::Path;
use serde::Deserialize;
use super::format::VideoQuality;
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(())
}
#[derive(Deserialize, Default)]
struct FfprobeStream {
width: Option<u32>,
height: Option<u32>,
}
#[derive(Deserialize, Default)]
struct FfprobeStreamGroupComponent {
width: Option<u32>,
height: Option<u32>,
}
#[derive(Deserialize, Default)]
struct FfprobeStreamGroup {
#[serde(default)]
components: Vec<FfprobeStreamGroupComponent>,
}
#[derive(Deserialize, Default)]
struct FfprobeDimensionsOutput {
#[serde(default)]
streams: Vec<FfprobeStream>,
#[serde(default)]
stream_groups: Vec<FfprobeStreamGroup>,
}
async fn probe_dimensions(path: &Path) -> Option<(u32, u32)> {
let output = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-print_format",
"json",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-show_stream_groups",
])
.arg(path)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let parsed: FfprobeDimensionsOutput = serde_json::from_slice(&output.stdout).ok()?;
if let Some(component) = parsed
.stream_groups
.first()
.and_then(|group| group.components.first())
&& let (Some(width), Some(height)) = (component.width, component.height)
{
return Some((width, height));
}
let stream = parsed.streams.first()?;
Some((stream.width?, stream.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,
) {
let before_area = before.0 as u64 * before.1 as u64;
let after_area = after.0 as u64 * after.1 as u64;
if before_area != after_area {
return Err(format!(
"transcoding {} changed pixel area from {before:?} ({before_area} px) to \
{after:?} ({after_area} px); refusing an output that isn't full-size",
input.display()
));
}
}
Ok(())
}
const DURATION_TOLERANCE_SECONDS: f64 = 0.5;
async fn probe_duration_seconds(path: &Path) -> Option<f64> {
let output = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(path)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<f64>()
.ok()
}
async fn probe_video_diagnostics(path: &Path) -> String {
let output = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-print_format",
"json",
"-show_entries",
"stream=index,codec_type,width,height,sample_aspect_ratio,display_aspect_ratio:\
stream_side_data=side_data_type,rotation,crop_top,crop_bottom,crop_left,crop_right:\
stream_tags=rotate",
])
.arg(path)
.output()
.await;
match output {
Ok(o) if o.status.success() => {
format!(
" (diagnostics: {})",
String::from_utf8_lossy(&o.stdout).trim()
)
}
_ => String::new(),
}
}
pub(crate) async fn transcode_video(
input: &Path,
output: &Path,
quality: VideoQuality,
threads: usize,
) -> Result<(), String> {
let mut command = tokio::process::Command::new("ffmpeg");
command.args([
"-y",
"-loglevel",
"error",
"-noautorotate",
"-apply_cropping:v",
"codec",
]);
command.arg("-i").arg(input);
command.args(["-map", "0:v:0", "-map", "0:a:0?"]);
command.args(["-c:v", "libx265", "-tag:v", "hvc1", "-preset", "medium"]);
command.args(["-threads", &threads.max(1).to_string()]);
match quality.crf() {
Some(crf) => {
command.args(["-crf", &crf.to_string()]);
}
None => {
command.args(["-x265-params", "lossless=1"]);
}
}
command.args(["-c:a", "aac", "-b:a", "256k", "-f", "mp4"]);
command.arg(output);
let result = command
.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,
) {
let before_area = before.0 as u64 * before.1 as u64;
let after_area = after.0 as u64 * after.1 as u64;
if before_area != after_area {
let diagnostics = probe_video_diagnostics(input).await;
return Err(format!(
"transcoding {} changed pixel area from {before:?} ({before_area} px) to \
{after:?} ({after_area} px); refusing an output that isn't full-size{diagnostics}",
input.display()
));
}
}
if let (Some(before), Some(after)) = (
probe_duration_seconds(input).await,
probe_duration_seconds(output).await,
) && (before - after).abs() > DURATION_TOLERANCE_SECONDS
{
return Err(format!(
"transcoding {} changed duration from {before:.3}s to {after:.3}s (tolerance \
{DURATION_TOLERANCE_SECONDS}s); refusing a truncated or partial encode",
input.display()
));
}
Ok(())
}
pub(crate) fn is_non_retryable_transcode_error(err: &str) -> bool {
const NON_RETRYABLE_SIGNATURES: &[&str] = &[
"moov atom not found",
"Invalid data found when processing input",
"Decoder (codec",
"could not find codec parameters",
"refusing an output that isn't full-size",
"Unknown encoder",
];
NON_RETRYABLE_SIGNATURES
.iter()
.any(|signature| err.contains(signature))
}
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());
}
async fn generate_test_video(path: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=10",
"-f",
"lavfi",
"-i",
"anullsrc=duration=1",
"-c:v",
"libx264",
"-c:a",
"aac",
])
.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_video_preserves_dimensions_and_duration_for_a_real_video() {
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.mp4");
if generate_test_video(&input).await.is_err() {
eprintln!("skipping: this ffmpeg build can't generate a test fixture");
return;
}
let output = dir.path().join("test-output.mp4");
if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
eprintln!("skipping: this ffmpeg build can't encode libx265: {err}");
return;
}
assert!(output.exists());
assert_eq!(
probe_dimensions(&input).await,
probe_dimensions(&output).await
);
let before_duration = probe_duration_seconds(&input).await.unwrap();
let after_duration = probe_duration_seconds(&output).await.unwrap();
assert!((before_duration - after_duration).abs() <= DURATION_TOLERANCE_SECONDS);
}
async fn generate_test_video_with_rotation(path: &Path, degrees: i32) -> Result<(), String> {
let base_path = path.with_file_name("rotation-base.mp4");
generate_test_video(&base_path).await?;
let result = tokio::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-display_rotation"])
.arg(degrees.to_string())
.arg("-i")
.arg(&base_path)
.args(["-c", "copy", "-f", "mp4"])
.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_video_preserves_raw_dimensions_for_an_input_with_a_rotation_display_matrix()
{
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("rotated.mp4");
if generate_test_video_with_rotation(&input, 90).await.is_err() {
eprintln!("skipping: this ffmpeg build can't attach a display-rotation matrix");
return;
}
let output = dir.path().join("rotated-output.mp4");
if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
panic!("transcode_video incorrectly rejected a rotated-but-unreshaped input: {err}");
}
assert_eq!(
probe_dimensions(&input).await,
probe_dimensions(&output).await
);
}
async fn generate_test_video_with_sps_crop(path: &Path) -> Result<(), String> {
let base_path = path.with_file_name("sps-crop-base.mp4");
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=5",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
])
.arg(&base_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());
}
let result = tokio::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-i"])
.arg(&base_path)
.args([
"-c:v",
"copy",
"-bsf:v",
"h264_metadata=crop_left=2:crop_right=2",
"-f",
"mp4",
])
.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_video_preserves_codec_level_cropped_dimensions() {
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("sps-cropped.mp4");
if generate_test_video_with_sps_crop(&input).await.is_err() {
eprintln!("skipping: this ffmpeg build can't attach an SPS crop via h264_metadata");
return;
}
let output = dir.path().join("sps-cropped-output.mp4");
if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
panic!("transcode_video incorrectly rejected a codec-level-cropped input: {err}");
}
assert_eq!(
probe_dimensions(&input).await,
probe_dimensions(&output).await
);
}
#[tokio::test]
async fn transcode_video_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.mp4");
if generate_test_video(&input).await.is_err() {
eprintln!("skipping: this ffmpeg build can't generate a test fixture");
return;
}
let output = dir.path().join("test-abcdef0123456789.mp4.scratch");
if let Err(err) = transcode_video(&input, &output, VideoQuality::Low, 1).await {
eprintln!("skipping: this ffmpeg build can't encode libx265: {err}");
return;
}
assert!(output.exists());
}
#[tokio::test]
async fn transcode_video_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-a-video.mp4");
std::fs::write(&input, b"this is not a real mp4 file").unwrap();
let output = dir.path().join("out.mp4");
let result = transcode_video(&input, &output, VideoQuality::Medium, 1).await;
assert!(result.is_err());
}
#[test]
fn is_non_retryable_transcode_error_matches_every_known_deterministic_signature() {
assert!(is_non_retryable_transcode_error(
"ffmpeg failed to transcode /x.heic: [mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found\n\
/x.heic: Invalid data found when processing input"
));
assert!(is_non_retryable_transcode_error(
"ffmpeg failed to transcode /x.heic: Invalid data found when processing input"
));
assert!(is_non_retryable_transcode_error(
"ffmpeg: Decoder (codec heic) not found for input stream"
));
assert!(is_non_retryable_transcode_error(
"ffmpeg failed to transcode /x.mov: could not find codec parameters"
));
assert!(is_non_retryable_transcode_error(
"transcoding /x.png changed pixel area from (100, 100) (10000 px) to (0, 0) (0 px); \
refusing an output that isn't full-size"
));
assert!(is_non_retryable_transcode_error(
"ffmpeg failed to transcode /x.mp4: Unknown encoder 'libx265'"
));
}
#[test]
fn is_non_retryable_transcode_error_does_not_match_a_plausible_transient_error() {
assert!(!is_non_retryable_transcode_error(
"failed to run ffmpeg: Resource temporarily unavailable (os error 11)"
));
assert!(!is_non_retryable_transcode_error(
"ffmpeg failed to transcode /x.png: Cannot allocate memory"
));
}
#[test]
fn is_non_retryable_transcode_error_is_false_for_an_empty_string() {
assert!(!is_non_retryable_transcode_error(""));
}
#[tokio::test]
async fn probe_dimensions_falls_back_to_the_plain_stream_when_there_is_no_stream_group() {
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();
assert_eq!(probe_dimensions(&input).await, Some((64, 48)));
}
#[test]
fn dimension_guard_area_comparison_accepts_a_transpose_and_rejects_a_real_mismatch() {
let transposed_a: (u32, u32) = (4032, 3024);
let transposed_b: (u32, u32) = (3024, 4032);
let area_a = transposed_a.0 as u64 * transposed_a.1 as u64;
let area_b = transposed_b.0 as u64 * transposed_b.1 as u64;
assert_eq!(area_a, area_b);
let truncated: (u32, u32) = (512, 512);
let area_truncated = truncated.0 as u64 * truncated.1 as u64;
assert_ne!(area_a, area_truncated);
}
}