avplan: whether a recording plays, and the one call that fixes it

This commit is contained in:
funkpower
2026-09-19 15:07:33 -04:00
parent ea0a3694b1
commit 49f12c9718
16 changed files with 1370 additions and 4 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "avplan"
description = "Whether a recording plays as it is, and the cheapest way to fix it if not."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
catena-cli.workspace = true
clap.workspace = true
corpus-core.workspace = true
regex.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
[lints]
workspace = true
+163
View File
@@ -0,0 +1,163 @@
# avplan
Says whether a recording plays as it is, and prints the one `ffmpeg` call that
fixes it when it does not.
Part of [catena](../../README.md).
## What it does
Reads what a file's streams actually are and does the least that fixes them.
An already-H.264 picture is copied rather than re-encoded, which is the
difference between forty minutes and forty seconds.
It runs nothing. What comes out is a command to read, pipe, or run — which is
the point: a re-encode is expensive and the original is irreplaceable.
## When you would use it
- **Before a flight.** Convert the six files that need it rather than the
ninety in the directory.
- **Sorting a download folder.** A file that plays needs nothing; knowing
which ones those are saves the rest of the work.
- **Diagnosing a video that opens to a black screen** — or plays with no
sound at all, which is what Opus in an MP4 looks like on a device that
cannot decode it.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena avplan
```
Needs `ffprobe` and `ffmpeg` on `PATH`. They ship together.
## Example
A VP9/Opus recording in a container nothing will open:
```
$ avplan lecture.webm
ffmpeg -nostdin -y -i lecture.webm -c:v h264_videotoolbox -q:v 60 -profile:v high -pix_fmt yuv420p -c:a aac -b:a 160k -ac 2 -movflags +faststart lecture.h264.mp4
```
Nothing to do:
```
$ avplan talk.mp4
$ echo $?
1
```
An H.264 picture in a Matroska container, with the sound in the wrong codec.
The picture is copied — only the sound is re-encoded, and the file is rewritten
into a container that opens:
```
$ avplan film.mkv
ffmpeg -nostdin -y -i film.mkv -c:v copy -c:a aac -b:a 160k -ac 2 -movflags +faststart film.h264.mp4
```
Why, rather than what:
```
$ avplan --json film.mkv | jq -c '{playable, reasons, encoder}'
{"playable":false,"reasons":["the sound is opus","a .mkv container cannot be opened"],"encoder":"copy"}
```
Converting everything in a directory that needs it:
```sh
for f in *.webm *.mkv; do avplan "$f" | xargs -d '\n' ffmpeg; done
```
`avplan` writes nothing for a file that already plays, so `xargs` runs nothing
for it either.
## Options
| Option | Effect |
|---|---|
| `--json` | Emit a record — the streams, the reasons, the arguments |
| `--output <PATH>` | Where the converted copy goes. Defaults to `<name>.h264.mp4` beside the original |
| `--encoder <NAME>` | Use this encoder rather than the best one installed |
| `--vaapi-device <PATH>` | The render node to encode on. Defaults to `/dev/dri/renderD128` |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | It needs converting, and the command is on stdout |
| `1` | It plays as it is |
| `2` | `ffprobe` could not read it |
| `64` | The command line was wrong |
| `69` | `ffmpeg` or `ffprobe` is not installed, or has no H.264 encoder |
| `74` | The file is not there |
Exit `1` is the good answer, not a failure.
## How it decides
Three questions, and any one of them failing means the file needs work.
**The picture.** H.264 or HEVC plays; anything else — VP9, AV1, VP8, MPEG-4 —
does not. This is the only expensive question: a picture that already plays is
copied through untouched.
**The sound.** AAC plays. Opus in an MP4 is the quiet failure worth naming,
because the file opens, the picture runs, and there is simply no sound.
**The container.** `.mp4`, `.m4v` and `.mov` can be opened. Matroska cannot,
whatever is inside it — which is why a `.mkv` full of H.264 and AAC still has
to be rewritten, though rewriting it is nearly free.
## Encoders
The encoder is found rather than configured: `ffmpeg -encoders` is asked what
this machine has, and the best of them is taken.
| Encoder | Where |
|---|---|
| `h264_nvenc` | NVIDIA |
| `h264_videotoolbox` | Apple silicon and recent Macs |
| `h264_vaapi` | AMD and Intel graphics |
| `libx264` | Software. Always there, and slower by a wide margin |
Hardware first, because the difference on a two-hour recording is minutes
against most of an hour. Each gets asked for roughly the same quality in
whichever of the four quality dialects it speaks:
```
$ avplan --encoder libx264 lecture.webm
ffmpeg -nostdin -y -i lecture.webm -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 160k -ac 2 -movflags +faststart lecture.h264.mp4
```
An `ffmpeg` built without any of the four cannot produce H.264 at all, and
`avplan` says so with exit `69` rather than printing a command that would
fail.
## As a library
```rust
let streams = avplan::Streams {
video: Some("vp9".into()),
audio: Some("opus".into()),
container: avplan::container_of("lecture.webm"),
};
let plan = avplan::plan(
"lecture.webm",
&streams,
Some(avplan::Encoder::Libx264),
"lecture.h264.mp4",
avplan::DEFAULT_VAAPI_DEVICE,
);
assert!(!plan.playable);
```
`video_plays`, `audio_plays` and `container_plays` answer the three questions
on their own, for anything that needs one of them without the rest.
## See also
- [`cuenorm`](../cuenorm/README.md) — the subtitles that go with the recording.
- [The record contract](../../docs/records.md) — what `--json` emits.
+224
View File
@@ -0,0 +1,224 @@
//! Choosing an encoder from the ones a machine actually has.
use std::sync::LazyLock;
use regex::Regex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// A line of an encoder listing: flags, then the name.
static LISTED: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^\s*[A-Z.]{6}\s+(\S+)").expect("a valid pattern"));
/// An H.264 encoder, in the order they are preferred.
///
/// Hardware first, because a re-encode of a two-hour recording is the
/// difference between three minutes and forty. The software encoder is last
/// and is always there.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Encoder {
/// NVIDIA's hardware encoder.
H264Nvenc,
/// Apple's, on Apple silicon and recent Macs.
H264Videotoolbox,
/// The one AMD and Intel graphics offer through VAAPI.
H264Vaapi,
/// Software. Always available, and the slowest by a wide margin.
Libx264,
/// The stream is already H.264 or HEVC and is copied rather than encoded.
Copy,
}
impl Encoder {
/// What ffmpeg calls it.
pub fn name(self) -> &'static str {
match self {
Encoder::H264Nvenc => "h264_nvenc",
Encoder::H264Videotoolbox => "h264_videotoolbox",
Encoder::H264Vaapi => "h264_vaapi",
Encoder::Libx264 => "libx264",
Encoder::Copy => "copy",
}
}
/// Whether this one runs on the graphics card.
pub fn is_hardware(self) -> bool {
matches!(
self,
Encoder::H264Nvenc | Encoder::H264Videotoolbox | Encoder::H264Vaapi
)
}
/// The arguments that have to come before the input.
///
/// Only VAAPI needs any: the device has to be opened before the file is,
/// so a filter can upload frames to it.
pub fn before_input(self, vaapi_device: &str) -> Vec<String> {
match self {
Encoder::H264Vaapi => vec![
"-init_hw_device".into(),
format!("vaapi=va:{vaapi_device}"),
"-filter_hw_device".into(),
"va".into(),
],
_ => Vec::new(),
}
}
/// The arguments that set up the video stream.
///
/// Each encoder is asked for roughly the same quality, in whichever of
/// the four quality dialects it happens to speak.
pub fn video_arguments(self) -> Vec<String> {
let args: &[&str] = match self {
Encoder::Copy => &["-c:v", "copy"],
Encoder::H264Nvenc => &[
"-c:v",
"h264_nvenc",
"-preset",
"p5",
"-rc",
"vbr",
"-cq",
"23",
"-b:v",
"0",
],
Encoder::H264Videotoolbox => &[
"-c:v",
"h264_videotoolbox",
"-q:v",
"60",
"-profile:v",
"high",
"-pix_fmt",
"yuv420p",
],
// Software decode and an upload to the card, so that a source the
// GPU's own decoder does not understand still encodes.
Encoder::H264Vaapi => &[
"-vf",
"format=nv12,hwupload",
"-c:v",
"h264_vaapi",
"-qp",
"23",
],
Encoder::Libx264 => &[
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
],
};
args.iter().map(|arg| (*arg).to_string()).collect()
}
}
/// The encoders this knows how to use, best first.
pub const PREFERRED: &[Encoder] = &[
Encoder::H264Nvenc,
Encoder::H264Videotoolbox,
Encoder::H264Vaapi,
Encoder::Libx264,
];
/// The best encoder present in an encoder listing.
///
/// `None` when the listing has none of them, which means the installed
/// ffmpeg cannot produce H.264 at all.
pub fn best_available(listing: &str) -> Option<Encoder> {
let available: Vec<&str> = LISTED
.captures_iter(listing)
.map(|found| found.get(1).expect("a name").as_str())
.collect();
PREFERRED
.iter()
.copied()
.find(|encoder| available.contains(&encoder.name()))
}
#[cfg(test)]
mod tests {
use super::*;
const LISTING: &str = "\
Encoders:
V..... = Video
------
V....D libx264 libx264 H.264 / AVC / MPEG-4 AVC
V....D libx265 libx265 HEVC
V..... h264_videotoolbox VideoToolbox H.264 Encoder
A..... aac AAC (Advanced Audio Coding)
";
#[test]
fn the_best_one_present_is_chosen() {
assert_eq!(best_available(LISTING), Some(Encoder::H264Videotoolbox));
}
#[test]
fn hardware_beats_software() {
let with_nvenc = format!("{LISTING} V..... h264_nvenc NVIDIA NVENC H.264\n");
assert_eq!(best_available(&with_nvenc), Some(Encoder::H264Nvenc));
}
#[test]
fn software_is_the_last_resort_and_not_the_first() {
let software_only = " V....D libx264 libx264 H.264\n";
assert_eq!(best_available(software_only), Some(Encoder::Libx264));
}
#[test]
fn an_ffmpeg_that_cannot_make_h264_says_so() {
assert_eq!(
best_available(" V....D libx265 libx265 HEVC\n"),
None
);
assert_eq!(best_available(""), None);
}
#[test]
fn a_name_that_merely_contains_an_encoder_name_is_not_one() {
// `libx264rgb` is a different encoder, and matching loosely would
// pick it for a plan that then produces the wrong pixel format.
let other = " V....D libx264rgb libx264 H.264 RGB\n";
assert_eq!(best_available(other), None);
}
#[test]
fn only_vaapi_needs_the_device_opened_first() {
assert_eq!(
Encoder::H264Vaapi.before_input("/dev/dri/renderD128"),
[
"-init_hw_device",
"vaapi=va:/dev/dri/renderD128",
"-filter_hw_device",
"va"
]
);
assert!(
Encoder::H264Nvenc
.before_input("/dev/dri/renderD128")
.is_empty()
);
assert!(Encoder::Libx264.before_input("x").is_empty());
}
#[test]
fn every_encoder_names_itself_in_its_own_arguments() {
for encoder in PREFERRED {
let args = encoder.video_arguments();
assert!(
args.contains(&encoder.name().to_string()),
"{:?} does not name itself",
encoder
);
}
}
#[test]
fn copying_is_not_encoding() {
assert!(!Encoder::Copy.is_hardware());
assert_eq!(Encoder::Copy.video_arguments(), ["-c:v", "copy"]);
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Whether a recording plays as it is, and the cheapest way to fix it if not.
//!
//! A player on a phone or a tablet decodes a short list of things. A file
//! outside that list does not fail loudly — it plays with no picture, or no
//! sound, or refuses to open with an error that names nothing. Converting
//! everything to be safe costs hours of encoding for files that were already
//! fine.
//!
//! So: read what the streams actually are, and do the least that fixes them.
//! An already-H.264 picture is copied rather than re-encoded, which turns
//! forty minutes of work into forty seconds of copying. Nothing here runs
//! ffmpeg; it prints the call.
pub mod encoder;
use std::sync::LazyLock;
use regex::Regex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use encoder::Encoder;
/// Video a player decodes natively.
static NATIVE_VIDEO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(avc1|h264|hvc1|hev1|hevc)").expect("a valid pattern"));
/// Audio a player decodes natively.
///
/// Opus inside an MP4 gives a video with no sound at all, which looks like a
/// broken file rather than a codec problem.
static NATIVE_AUDIO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(aac|mp4a)").expect("a valid pattern"));
/// Containers a player can open.
///
/// Matroska is not among them whatever is inside it, which is why the
/// container is checked separately from the streams.
const PLAYABLE_CONTAINERS: &[&str] = &["m4v", "mov", "mp4"];
/// The audio settings a re-encode uses: stereo, and enough bitrate that
/// speech is indistinguishable from the original.
const AUDIO_ARGUMENTS: &[&str] = &["-c:a", "aac", "-b:a", "160k", "-ac", "2"];
/// The render node a machine's first graphics card usually appears as.
pub const DEFAULT_VAAPI_DEVICE: &str = "/dev/dri/renderD128";
/// What a file's streams are.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Streams {
/// The video codec, as the prober names it.
pub video: Option<String>,
/// The audio codec.
pub audio: Option<String>,
/// The file's extension, lowercased, without the dot.
pub container: Option<String>,
}
/// What has to happen to a file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Plan {
/// Record version.
#[serde(default = "version")]
pub v: u32,
/// The file the plan is about.
pub path: String,
/// Whether it plays as it stands.
pub playable: bool,
/// What its streams are.
pub streams: Streams,
/// Why it does not play, in the order the reasons were found. Empty when
/// it does.
pub reasons: Vec<String>,
/// Which encoder the picture would go through, or `copy`.
pub encoder: Option<Encoder>,
/// Whether the sound is copied rather than re-encoded.
pub copies_audio: bool,
/// The call that would fix it, argument by argument. Empty when nothing
/// needs doing.
pub argv: Vec<String>,
}
fn version() -> u32 {
corpus_core::V
}
impl corpus_core::Record for Plan {
fn version(&self) -> u32 {
self.v
}
}
/// Whether a video stream plays as it is.
pub fn video_plays(codec: Option<&str>) -> bool {
codec.is_some_and(|codec| NATIVE_VIDEO.is_match(codec))
}
/// Whether an audio stream plays as it is.
pub fn audio_plays(codec: Option<&str>) -> bool {
codec.is_some_and(|codec| NATIVE_AUDIO.is_match(codec))
}
/// Whether a container can be opened at all.
pub fn container_plays(extension: Option<&str>) -> bool {
extension.is_some_and(|extension| {
PLAYABLE_CONTAINERS
.binary_search(&extension.to_lowercase().as_str())
.is_ok()
})
}
/// The extension of a path, lowercased, without the dot.
pub fn container_of(path: &str) -> Option<String> {
std::path::Path::new(path)
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase())
}
/// Where a converted copy goes: beside the original, named for what it is.
pub fn output_beside(path: &str) -> String {
let path = std::path::Path::new(path);
let stem = path.file_stem().map(|stem| stem.to_string_lossy());
match (path.parent(), stem) {
(Some(parent), Some(stem)) if !parent.as_os_str().is_empty() => {
format!("{}/{stem}.h264.mp4", parent.display())
}
(_, Some(stem)) => format!("{stem}.h264.mp4"),
_ => format!("{}.h264.mp4", path.display()),
}
}
/// Judge a file and, if it needs converting, say how.
///
/// `available` is the best encoder the machine has; it is only consulted when
/// the picture actually has to be re-encoded.
pub fn plan(
path: &str,
streams: &Streams,
available: Option<Encoder>,
output: &str,
vaapi_device: &str,
) -> Plan {
let video_ok = video_plays(streams.video.as_deref());
let audio_ok = audio_plays(streams.audio.as_deref());
let container_ok = container_plays(streams.container.as_deref());
let mut reasons = Vec::new();
if !video_ok {
reasons.push(match streams.video.as_deref() {
None | Some("") => "no video stream this can read".to_string(),
Some(codec) => format!("the picture is {codec}"),
});
}
if !audio_ok {
reasons.push(match streams.audio.as_deref() {
None | Some("") => "no audio stream this can read".to_string(),
Some(codec) => format!("the sound is {codec}"),
});
}
if !container_ok {
reasons.push(match streams.container.as_deref() {
None => "the file has no extension to go on".to_string(),
Some(extension) => format!("a .{extension} container cannot be opened"),
});
}
if reasons.is_empty() {
return Plan {
v: corpus_core::V,
path: path.to_string(),
playable: true,
streams: streams.clone(),
reasons,
encoder: None,
copies_audio: true,
argv: Vec::new(),
};
}
// The picture is the expensive part, and it only has to be re-encoded
// when it is not already something a player decodes. A file whose only
// problem is its container or its sound is a copy and a remux.
let encoder = match video_ok {
true => Some(Encoder::Copy),
false => available,
};
let argv = match encoder {
None => Vec::new(),
Some(encoder) => {
let mut argv = vec!["-nostdin".to_string(), "-y".to_string()];
argv.extend(encoder.before_input(vaapi_device));
argv.push("-i".into());
argv.push(path.to_string());
argv.extend(encoder.video_arguments());
match audio_ok {
true => argv.extend(["-c:a".to_string(), "copy".to_string()]),
false => argv.extend(AUDIO_ARGUMENTS.iter().map(|arg| (*arg).to_string())),
}
// So a player can start before the whole file has arrived.
argv.extend(["-movflags".to_string(), "+faststart".to_string()]);
argv.push(output.to_string());
argv
}
};
Plan {
v: corpus_core::V,
path: path.to_string(),
playable: false,
streams: streams.clone(),
reasons,
encoder,
copies_audio: audio_ok,
argv,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn streams(video: &str, audio: &str, container: &str) -> Streams {
Streams {
video: Some(video.into()),
audio: Some(audio.into()),
container: Some(container.into()),
}
}
fn plan_for(path: &str, streams: &Streams) -> Plan {
plan(
path,
streams,
Some(Encoder::H264Nvenc),
&output_beside(path),
DEFAULT_VAAPI_DEVICE,
)
}
#[test]
fn the_container_list_is_sorted_so_it_can_be_searched() {
assert!(PLAYABLE_CONTAINERS.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn a_file_that_already_plays_needs_nothing() {
let found = plan_for("talk.mp4", &streams("h264", "aac", "mp4"));
assert!(found.playable);
assert!(found.argv.is_empty());
assert!(found.reasons.is_empty());
assert_eq!(found.encoder, None);
}
#[test]
fn the_codec_spellings_a_prober_uses_are_all_recognised() {
for video in [
"h264",
"avc1",
"avc1.640028",
"hevc",
"hvc1",
"hev1",
"H264",
] {
assert!(video_plays(Some(video)), "{video}");
}
for audio in ["aac", "mp4a", "mp4a.40.2", "AAC"] {
assert!(audio_plays(Some(audio)), "{audio}");
}
assert!(!video_plays(Some("vp9")));
assert!(!audio_plays(Some("opus")));
assert!(!video_plays(None));
}
#[test]
fn a_picture_that_already_plays_is_copied_rather_than_re_encoded() {
let found = plan_for("talk.mkv", &streams("h264", "aac", "mkv"));
assert!(!found.playable);
assert_eq!(found.encoder, Some(Encoder::Copy));
assert!(found.copies_audio);
assert!(
found.argv.windows(2).any(|pair| pair == ["-c:v", "copy"]),
"{:?}",
found.argv
);
assert!(found.argv.windows(2).any(|pair| pair == ["-c:a", "copy"]));
}
#[test]
fn only_the_sound_being_wrong_costs_only_the_sound() {
let found = plan_for("talk.mp4", &streams("h264", "opus", "mp4"));
assert_eq!(found.encoder, Some(Encoder::Copy));
assert!(!found.copies_audio);
assert!(found.argv.windows(2).any(|pair| pair == ["-c:v", "copy"]));
assert!(found.argv.windows(2).any(|pair| pair == ["-c:a", "aac"]));
assert_eq!(found.reasons, ["the sound is opus"]);
}
#[test]
fn a_picture_that_does_not_play_is_re_encoded() {
let found = plan_for("talk.webm", &streams("vp9", "opus", "webm"));
assert_eq!(found.encoder, Some(Encoder::H264Nvenc));
assert!(found.argv.contains(&"h264_nvenc".to_string()));
assert_eq!(
found.reasons,
[
"the picture is vp9",
"the sound is opus",
"a .webm container cannot be opened"
]
);
}
#[test]
fn the_call_is_in_the_order_ffmpeg_wants_it() {
let found = plan(
"talk.webm",
&streams("av1", "opus", "webm"),
Some(Encoder::H264Vaapi),
"out.mp4",
"/dev/dri/renderD129",
);
let at = |arg: &str| found.argv.iter().position(|a| a == arg).expect(arg);
assert!(at("-init_hw_device") < at("-i"), "the device opens first");
assert!(at("-i") < at("-c:v"), "the input comes before the encoder");
assert!(at("-c:v") < at("-movflags"));
assert_eq!(found.argv.last().map(String::as_str), Some("out.mp4"));
assert!(
found
.argv
.contains(&"vaapi=va:/dev/dri/renderD129".to_string())
);
}
#[test]
fn a_machine_with_no_h264_encoder_gets_no_call() {
let found = plan(
"talk.webm",
&streams("vp9", "opus", "webm"),
None,
"out.mp4",
DEFAULT_VAAPI_DEVICE,
);
assert!(!found.playable);
assert_eq!(found.encoder, None);
assert!(found.argv.is_empty(), "nothing on this machine could do it");
}
#[test]
fn a_missing_stream_is_a_reason_of_its_own() {
let found = plan_for(
"sound.mp4",
&Streams {
video: None,
audio: Some("aac".into()),
container: Some("mp4".into()),
},
);
assert_eq!(found.reasons, ["no video stream this can read"]);
}
#[test]
fn the_converted_copy_goes_beside_the_original() {
assert_eq!(output_beside("talk.webm"), "talk.h264.mp4");
assert_eq!(output_beside("a/b/talk.webm"), "a/b/talk.h264.mp4");
assert_eq!(output_beside("talk"), "talk.h264.mp4");
}
#[test]
fn a_container_is_read_from_the_name() {
assert_eq!(container_of("a/b/talk.WEBM").as_deref(), Some("webm"));
assert_eq!(container_of("talk").as_deref(), None);
assert!(container_plays(Some("MP4")));
assert!(!container_plays(Some("mkv")));
assert!(!container_plays(None));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Whether a recording plays as it is, and the cheapest way to fix it if not.
use std::path::PathBuf;
use std::process::{Command, ExitCode};
use avplan::{Encoder, Streams};
use clap::Parser;
use corpus_core::exit;
/// Say whether a recording plays as it stands, and print the one `ffmpeg`
/// call that fixes it when it does not.
///
/// Runs nothing. What comes out is a command to look at, pipe somewhere, or
/// run — which is the point: converting is expensive and irreversible enough
/// to be worth reading first.
///
/// Shells out to `ffprobe` to read the streams and to `ffmpeg` to ask which
/// encoders this machine has. Both ship together.
#[derive(Parser)]
#[command(
version,
about,
after_help = "\
Exit codes: 0 it needs converting, 1 it plays as it is, 2 could not tell,
69 ffmpeg or ffprobe is not installed, 74 the file could not be read.
Example:
for f in *.webm; do avplan \"$f\" | xargs -d '\\n' ffmpeg; done
avplan --json talk.mkv | jq -r '.reasons[]'"
)]
struct Args {
/// The recording to judge.
path: PathBuf,
/// Emit a record instead of a command.
#[arg(long)]
json: bool,
/// Where the converted copy should go. Defaults to beside the original.
#[arg(long, value_name = "PATH")]
output: Option<PathBuf>,
/// The render node to encode on, where the encoder needs one named.
#[arg(long, default_value = avplan::DEFAULT_VAAPI_DEVICE, value_name = "PATH")]
vaapi_device: String,
/// Use this encoder rather than the best one installed.
#[arg(long, value_name = "NAME")]
encoder: Option<String>,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
if !args.path.exists() {
eprintln!("avplan: {}: no such file", args.path.display());
return ExitCode::from(exit::IO);
}
for program in ["ffprobe", "ffmpeg"] {
if which(program).is_none() {
eprintln!("avplan: {program} is not on PATH");
return ExitCode::from(exit::UNAVAILABLE);
}
}
let path = args.path.display().to_string();
let Some(video) = probe(&args.path, "v:0") else {
eprintln!("avplan: ffprobe could not read {path}");
return ExitCode::from(exit::INDETERMINATE);
};
let audio = probe(&args.path, "a:0").unwrap_or_default();
let streams = Streams {
video: Some(video).filter(|codec| !codec.is_empty()),
audio: Some(audio).filter(|codec| !codec.is_empty()),
container: avplan::container_of(&path),
};
let encoder = match args.encoder.as_deref() {
None => installed(),
Some(name) => match avplan::encoder::PREFERRED
.iter()
.find(|encoder| encoder.name() == name)
{
Some(encoder) => Some(*encoder),
None => {
eprintln!("avplan: {name:?} is not an encoder this knows");
return ExitCode::from(exit::USAGE);
}
},
};
let output = args
.output
.map(|path| path.display().to_string())
.unwrap_or_else(|| avplan::output_beside(&path));
let plan = avplan::plan(&path, &streams, encoder, &output, &args.vaapi_device);
// The record form always writes, because the reasons are the answer even
// when nothing can act on them. The command form has nothing to say
// about a file that plays, and nothing to offer for one this machine
// cannot convert.
if args.json {
let json = serde_json::to_string(&plan).expect("a plan serialises");
return leave(catena_cli::text("avplan", &format!("{json}\n")), &plan);
}
if plan.playable {
return ExitCode::from(exit::NO);
}
if plan.argv.is_empty() {
eprintln!("avplan: no H.264 encoder on this machine, so nothing here can convert it");
return ExitCode::from(exit::UNAVAILABLE);
}
leave(
catena_cli::text("avplan", &format!("{}\n", quoted(&plan.argv))),
&plan,
)
}
/// Leave with the verdict, unless writing it failed.
fn leave(wrote: ExitCode, plan: &avplan::Plan) -> ExitCode {
if format!("{wrote:?}") != format!("{:?}", ExitCode::from(exit::OK)) {
return wrote;
}
ExitCode::from(match (plan.playable, plan.argv.is_empty()) {
(true, _) => exit::NO,
(false, true) => exit::UNAVAILABLE,
(false, false) => exit::OK,
})
}
/// A command line a person can read and a shell can run.
fn quoted(argv: &[String]) -> String {
std::iter::once("ffmpeg".to_string())
.chain(argv.iter().cloned())
.map(|arg| match arg.chars().all(is_bare) {
true => arg,
false => format!("'{}'", arg.replace('\'', r"'\''")),
})
.collect::<Vec<_>>()
.join(" ")
}
fn is_bare(c: char) -> bool {
c.is_ascii_alphanumeric() || "-_.:/=+,".contains(c)
}
/// The codec of one stream, or `None` if the file could not be read at all.
fn probe(path: &std::path::Path, stream: &str) -> Option<String> {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
stream,
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(path)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string(),
)
}
/// The best encoder this machine has.
fn installed() -> Option<Encoder> {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-encoders"])
.output()
.ok()?;
avplan::encoder::best_available(&String::from_utf8_lossy(&out.stdout))
}
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(program))
.find(|candidate| is_program(candidate))
}
#[cfg(unix)]
fn is_program(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_program(path: &std::path::Path) -> bool {
path.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_line_is_quoted_where_it_has_to_be() {
let argv = vec!["-i".into(), "a file.webm".into(), "out.mp4".into()];
assert_eq!(quoted(&argv), "ffmpeg -i 'a file.webm' out.mp4");
}
#[test]
fn an_apostrophe_in_a_name_does_not_end_the_quoting() {
let argv = vec!["-i".into(), "o'brien.webm".into()];
assert_eq!(quoted(&argv), r"ffmpeg -i 'o'\''brien.webm'");
}
#[test]
fn ordinary_arguments_are_left_bare() {
let argv = vec!["-c:v".into(), "libx264".into(), "-crf".into(), "23".into()];
assert_eq!(quoted(&argv), "ffmpeg -c:v libx264 -crf 23");
}
}