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(),
}
}
const NO_VIDEO_STREAM_SIGNATURE: &str = "has no video stream";
const UNEXPECTED_VIDEO_STREAM_SIGNATURE: &str = "is not an audio-only recording";
pub(crate) fn is_wrong_input_kind_error(err: &str) -> bool {
[NO_VIDEO_STREAM_SIGNATURE, UNEXPECTED_VIDEO_STREAM_SIGNATURE]
.iter()
.any(|signature| err.contains(signature))
}
async fn has_video_stream(path: &Path) -> bool {
let Ok(output) = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
])
.arg(path)
.output()
.await
else {
return false;
};
output.status.success() && !output.stdout.is_empty()
}
pub(crate) async fn transcode_video(
input: &Path,
output: &Path,
quality: VideoQuality,
threads: usize,
) -> Result<(), String> {
if !has_video_stream(input).await {
return Err(format!(
"{} {NO_VIDEO_STREAM_SIGNATURE} (likely an audio-only recording saved with a video \
extension); see ADR-0142 for an audio-preserving input type",
input.display()
));
}
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(())
}
const OPUS_BITRATE: &str = "48k";
pub(crate) async fn transcode_audio(input: &Path, output: &Path) -> Result<(), String> {
if has_video_stream(input).await {
return Err(format!(
"{} has a video stream, so it {UNEXPECTED_VIDEO_STREAM_SIGNATURE}; refusing to \
discard video (use --input-file-type mp4 to transcode it as video)",
input.display()
));
}
let result = tokio::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(input)
.args([
"-map",
"0:a:0",
"-c:a",
"libopus",
"-b:a",
OPUS_BITRATE,
"-application",
"audio",
"-f",
"opus",
])
.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_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",
NO_VIDEO_STREAM_SIGNATURE,
UNEXPECTED_VIDEO_STREAM_SIGNATURE,
];
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());
}
async fn generate_audio_only_mp4(path: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"anullsrc=duration=1",
"-c:a",
"aac",
"-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_fails_with_a_clear_error_on_an_audio_only_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("audioclip.mp4");
if generate_audio_only_mp4(&input).await.is_err() {
eprintln!("skipping: this ffmpeg build can't generate an audio-only fixture");
return;
}
assert!(!has_video_stream(&input).await);
let output = dir.path().join("out.mp4");
let err = transcode_video(&input, &output, VideoQuality::Medium, 1)
.await
.expect_err("an audio-only input must not transcode as video");
assert!(
err.contains("has no video stream"),
"unexpected error: {err}"
);
assert!(
is_non_retryable_transcode_error(&err),
"an audio-only input is deterministic and must never be retried: {err}"
);
assert!(
!output.exists(),
"ffmpeg must not have run at all for an audio-only input"
);
}
#[tokio::test]
async fn transcode_audio_produces_an_opus_preserving_duration_from_a_scratch_suffixed_path() {
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("voice.mp4");
if generate_audio_only_mp4(&input).await.is_err() {
eprintln!("skipping: this ffmpeg build can't generate an audio-only fixture");
return;
}
let output = dir.path().join("voice-abcdef0123456789.opus.scratch");
if let Err(err) = transcode_audio(&input, &output).await {
eprintln!("skipping: this ffmpeg build can't encode libopus: {err}");
return;
}
assert!(output.exists());
let before = probe_duration_seconds(&input).await.unwrap();
let after = probe_duration_seconds(&output).await.unwrap();
assert!(
(before - after).abs() <= DURATION_TOLERANCE_SECONDS,
"duration drifted: {before}s -> {after}s"
);
}
#[tokio::test]
async fn transcode_audio_refuses_an_input_that_still_has_a_video_stream() {
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("real-video.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("out.opus");
let err = transcode_audio(&input, &output)
.await
.expect_err("a file with a video stream must not be transcoded to audio-only");
assert!(
err.contains("is not an audio-only recording"),
"unexpected error: {err}"
);
assert!(
is_non_retryable_transcode_error(&err),
"this is deterministic and must never be retried: {err}"
);
assert!(
!output.exists(),
"ffmpeg must not have run at all for a video-bearing input"
);
}
#[tokio::test]
async fn transcode_audio_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-audio.mp4");
std::fs::write(&input, b"this is not a real mp4 file").unwrap();
let output = dir.path().join("out.opus");
assert!(transcode_audio(&input, &output).await.is_err());
}
#[tokio::test]
async fn has_video_stream_is_true_for_a_real_video_and_false_for_an_unreadable_file() {
if check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let video = dir.path().join("test.mp4");
if generate_test_video(&video).await.is_err() {
eprintln!("skipping: this ffmpeg build can't generate a test fixture");
return;
}
assert!(has_video_stream(&video).await);
let garbage = dir.path().join("not-a-video.mp4");
std::fs::write(&garbage, b"this is not a real mp4 file").unwrap();
assert!(!has_video_stream(&garbage).await);
assert!(!has_video_stream(&dir.path().join("does-not-exist.mp4")).await);
}
#[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'"
));
assert!(is_non_retryable_transcode_error(
"/x.mp4 has no video stream (likely an audio-only recording saved with a video \
extension); see ADR-0142 for an audio-preserving input type"
));
}
#[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(""));
}
#[test]
fn is_wrong_input_kind_error_matches_both_guards_and_nothing_else() {
assert!(is_wrong_input_kind_error(
"/x.mp4 has no video stream (likely an audio-only recording saved with a video \
extension); see ADR-0142 for an audio-preserving input type"
));
assert!(is_wrong_input_kind_error(
"/x.mp4 has a video stream, so it is not an audio-only recording; refusing to \
discard video (use --input-file-type mp4 to transcode it as video)"
));
assert!(!is_wrong_input_kind_error(
"ffmpeg failed to transcode /x.mp4: Invalid data found when processing input"
));
assert!(!is_wrong_input_kind_error(
"ffmpeg failed to transcode /x.mp4: moov atom not found"
));
assert!(!is_wrong_input_kind_error(
"ffmpeg failed to transcode /x.mp4: Unknown encoder 'libx265'"
));
assert!(!is_wrong_input_kind_error(
"failed to run ffmpeg: Resource temporarily unavailable (os error 11)"
));
assert!(!is_wrong_input_kind_error(""));
}
#[test]
fn both_wrong_input_kind_signatures_are_also_non_retryable() {
for signature in [NO_VIDEO_STREAM_SIGNATURE, UNEXPECTED_VIDEO_STREAM_SIGNATURE] {
let message = format!("/x.mp4 {signature} ...");
assert!(
is_non_retryable_transcode_error(&message),
"{signature} must be non-retryable"
);
assert!(
is_wrong_input_kind_error(&message),
"{signature} must be a wrong-input-kind 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);
}
}