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
Generated
+14
View File
@@ -73,6 +73,19 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "avplan"
version = "0.1.0"
dependencies = [
"catena-cli",
"clap",
"corpus-core",
"regex",
"schemars",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -693,6 +706,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
name = "parity" name = "parity"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"avplan",
"bibclean", "bibclean",
"booksniff", "booksniff",
"chunkmd", "chunkmd",
+1
View File
@@ -23,6 +23,7 @@ cueseg = { path = "crates/cueseg", version = "0.1.0" }
rrf = { path = "crates/rrf", version = "0.1.0" } rrf = { path = "crates/rrf", version = "0.1.0" }
cuepack = { path = "crates/cuepack", version = "0.1.0" } cuepack = { path = "crates/cuepack", version = "0.1.0" }
bibclean = { path = "crates/bibclean", version = "0.1.0" } bibclean = { path = "crates/bibclean", version = "0.1.0" }
avplan = { path = "crates/avplan", version = "0.1.0" }
booksniff = { path = "crates/booksniff", version = "0.1.0" } booksniff = { path = "crates/booksniff", version = "0.1.0" }
emb = { path = "crates/emb", version = "0.1.0" } emb = { path = "crates/emb", version = "0.1.0" }
+1
View File
@@ -35,6 +35,7 @@ Each tool installs by name, so you can take one without taking the rest.
| [`cueseg`](crates/cueseg/README.md) | A timed transcript into topic chapters | | [`cueseg`](crates/cueseg/README.md) | A timed transcript into topic chapters |
| [`emb`](crates/emb/README.md) | Lines into vectors, against an embedding server | | [`emb`](crates/emb/README.md) | Lines into vectors, against an embedding server |
| [`rrf`](crates/rrf/README.md) | Several ranked lists into one, without the database | | [`rrf`](crates/rrf/README.md) | Several ranked lists into one, without the database |
| [`avplan`](crates/avplan/README.md) | Whether a recording plays, and the one `ffmpeg` call that fixes it |
Each links to its own page: what it does, when you would use it, worked Each links to its own page: what it does, when you would use it, worked
examples, options and exit codes. examples, options and exit codes.
+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");
}
}
+1
View File
@@ -22,6 +22,7 @@ cuepack.workspace = true
cueseg.workspace = true cueseg.workspace = true
mdscrub.workspace = true mdscrub.workspace = true
mdsplit.workspace = true mdsplit.workspace = true
avplan.workspace = true
pdftriage.workspace = true pdftriage.workspace = true
rrf.workspace = true rrf.workspace = true
corpus-core.workspace = true corpus-core.workspace = true
+45
View File
@@ -156,3 +156,48 @@ exactly.
**Measured:** checked against the oracle for all of those combinations; only **Measured:** checked against the oracle for all of those combinations; only
the zero hard maximum differs. the zero hard maximum differs.
## avplan — the encoder is found rather than configured
**Original:** reads `COMPATIBLE_VIDEO_ENCODER` and trusts it, defaulting to
`h264_nvenc`. A machine with no NVIDIA card and an unset variable builds an
NVENC command line that fails the moment it runs.
**Rust:** asks `ffmpeg -encoders` what is installed and takes the best of
them, hardware first.
**Why this is acceptable:** an environment variable naming the encoder is
per-host configuration, and a filter that reads configuration is a filter
whose answer depends on where it ran. Detection reaches the same answer on
every machine the variable was set correctly on, and a better one on the
machines it was not. The encoder table itself — which arguments each encoder
gets — is identical, and `--encoder` names one explicitly for anyone who
needs to.
**Measured:** all four branches of the encoder table are compared against the
original in `cases/avplan`, each with the encoder named, so the argument
lists agree exactly.
## avplan — the output path is the destination, not a temporary
**Original:** writes to `<output>.tmp.mp4` and renames on success, so a
transcode killed halfway leaves no file that looks finished.
**Rust:** the command line names the destination.
**Why this is acceptable:** avplan does not run the command, so it cannot do
the rename half of the pattern, and a command line that writes to a temporary
nobody moves is worse than one that writes where it says. Anything running
these in a loop can write to a temporary of its own choosing with `--output`.
## avplan — a failed probe is an answer, not a fallback
**Original:** when `ffprobe` cannot read a file it falls back to the codec
recorded in the database, which came from the downloader's pre-download
metadata and is wrong often enough that the comment above it says so.
**Rust:** exits 2, could not tell.
**Why this is acceptable:** there is no database, and inventing a codec is
how a compatible file gets re-encoded for forty minutes. Saying so is the
honest answer and the cheap one.
+4 -3
View File
@@ -22,6 +22,7 @@ CATENA_PARITY_PYTHONPATH=/path/to/book-pipeline/src:/path/to/podcast-pipeline/sr
CATENA_PARITY_NODE=node # only if it is not already on PATH CATENA_PARITY_NODE=node # only if it is not already on PATH
``` ```
One oracle is JavaScript rather than Python, because the code it compares Two oracles are JavaScript rather than Python, because the code they compare
against is. Its three functions are copied into `oracle/cuenorm.mjs` verbatim, against is. Their functions are copied into `oracle/cuenorm.mjs` and
since the module they live in opens a database connection when it loads. `oracle/compatible.mjs` verbatim, since the modules they live in open a
database connection when they load.
+24
View File
@@ -0,0 +1,24 @@
{"name":"already-playable-mp4","path":"/media/talk.mp4","video":"h264","audio":"aac","encoder":"h264_nvenc"}
{"name":"already-playable-mov","path":"/media/talk.mov","video":"avc1.640028","audio":"mp4a.40.2","encoder":"h264_nvenc"}
{"name":"already-playable-m4v","path":"/media/talk.m4v","video":"hevc","audio":"aac","encoder":"h264_nvenc"}
{"name":"uppercase-extension","path":"/media/TALK.MP4","video":"h264","audio":"aac","encoder":"h264_nvenc"}
{"name":"vp9-opus-webm-nvenc","path":"/media/lecture.webm","video":"vp9","audio":"opus","encoder":"h264_nvenc"}
{"name":"vp9-opus-webm-videotoolbox","path":"/media/lecture.webm","video":"vp9","audio":"opus","encoder":"h264_videotoolbox"}
{"name":"vp9-opus-webm-vaapi","path":"/media/lecture.webm","video":"vp9","audio":"opus","encoder":"h264_vaapi"}
{"name":"vp9-opus-webm-libx264","path":"/media/lecture.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"vaapi-other-device","path":"/media/lecture.webm","video":"av1","audio":"opus","encoder":"h264_vaapi","vaapi_device":"/dev/dri/renderD129"}
{"name":"h264-in-matroska","path":"/media/film.mkv","video":"h264","audio":"aac","encoder":"h264_nvenc"}
{"name":"h264-opus-in-mp4","path":"/media/interview.mp4","video":"h264","audio":"opus","encoder":"libx264"}
{"name":"av1-aac-in-mp4","path":"/media/short.mp4","video":"av1","audio":"aac","encoder":"h264_nvenc"}
{"name":"no-audio-stream","path":"/media/silent.webm","video":"vp9","audio":"","encoder":"libx264"}
{"name":"no-video-stream","path":"/media/podcast.m4a","video":"","audio":"aac","encoder":"libx264"}
{"name":"neither-stream","path":"/media/broken.bin","video":"","audio":"","encoder":"libx264"}
{"name":"no-extension","path":"/media/download","video":"h264","audio":"aac","encoder":"libx264"}
{"name":"two-dots-in-the-name","path":"/media/s01.e04.webm","video":"vp8","audio":"vorbis","encoder":"libx264"}
{"name":"relative-path","path":"talk.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"space-in-the-name","path":"/media/a talk.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"named-output","path":"/media/lecture.webm","video":"vp9","audio":"opus","encoder":"libx264","output":"/elsewhere/out.mp4"}
{"name":"codec-merely-starting-with-aac","path":"/media/x.mp4","video":"h264","audio":"aac_latm","encoder":"libx264"}
{"name":"codec-merely-containing-h264","path":"/media/x.mp4","video":"prores_h264ish","audio":"aac","encoder":"libx264"}
{"name":"mpeg4-in-avi","path":"/media/old.avi","video":"mpeg4","audio":"mp3","encoder":"h264_nvenc"}
{"name":"hevc-in-mkv-aac","path":"/media/film.mkv","video":"hev1.1.6.L93","audio":"aac","encoder":"h264_nvenc"}
+24
View File
@@ -0,0 +1,24 @@
{"path":"/media/talk.mp4","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/talk.mov","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/talk.m4v","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/TALK.MP4","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/lecture.webm","playable":false,"encoder":"h264_nvenc","copies_audio":false,"argv":["-nostdin","-y","-i","/media/lecture.webm","-c:v","h264_nvenc","-preset","p5","-rc","vbr","-cq","23","-b:v","0","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/lecture.h264.mp4"]}
{"path":"/media/lecture.webm","playable":false,"encoder":"h264_videotoolbox","copies_audio":false,"argv":["-nostdin","-y","-i","/media/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","/media/lecture.h264.mp4"]}
{"path":"/media/lecture.webm","playable":false,"encoder":"h264_vaapi","copies_audio":false,"argv":["-nostdin","-y","-init_hw_device","vaapi=va:/dev/dri/renderD128","-filter_hw_device","va","-i","/media/lecture.webm","-vf","format=nv12,hwupload","-c:v","h264_vaapi","-qp","23","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/lecture.h264.mp4"]}
{"path":"/media/lecture.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/lecture.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/lecture.h264.mp4"]}
{"path":"/media/lecture.webm","playable":false,"encoder":"h264_vaapi","copies_audio":false,"argv":["-nostdin","-y","-init_hw_device","vaapi=va:/dev/dri/renderD129","-filter_hw_device","va","-i","/media/lecture.webm","-vf","format=nv12,hwupload","-c:v","h264_vaapi","-qp","23","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/lecture.h264.mp4"]}
{"path":"/media/film.mkv","playable":false,"encoder":"copy","copies_audio":true,"argv":["-nostdin","-y","-i","/media/film.mkv","-c:v","copy","-c:a","copy","-movflags","+faststart","/media/film.h264.mp4"]}
{"path":"/media/interview.mp4","playable":false,"encoder":"copy","copies_audio":false,"argv":["-nostdin","-y","-i","/media/interview.mp4","-c:v","copy","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/interview.h264.mp4"]}
{"path":"/media/short.mp4","playable":false,"encoder":"h264_nvenc","copies_audio":true,"argv":["-nostdin","-y","-i","/media/short.mp4","-c:v","h264_nvenc","-preset","p5","-rc","vbr","-cq","23","-b:v","0","-c:a","copy","-movflags","+faststart","/media/short.h264.mp4"]}
{"path":"/media/silent.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/silent.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/silent.h264.mp4"]}
{"path":"/media/podcast.m4a","playable":false,"encoder":"libx264","copies_audio":true,"argv":["-nostdin","-y","-i","/media/podcast.m4a","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","copy","-movflags","+faststart","/media/podcast.h264.mp4"]}
{"path":"/media/broken.bin","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/broken.bin","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/broken.h264.mp4"]}
{"path":"/media/download","playable":false,"encoder":"copy","copies_audio":true,"argv":["-nostdin","-y","-i","/media/download","-c:v","copy","-c:a","copy","-movflags","+faststart","/media/download.h264.mp4"]}
{"path":"/media/s01.e04.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/s01.e04.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/s01.e04.h264.mp4"]}
{"path":"talk.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","talk.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","talk.h264.mp4"]}
{"path":"/media/a talk.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/a talk.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/a talk.h264.mp4"]}
{"path":"/media/lecture.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/lecture.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/elsewhere/out.mp4"]}
{"path":"/media/x.mp4","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/x.mp4","playable":false,"encoder":"libx264","copies_audio":true,"argv":["-nostdin","-y","-i","/media/x.mp4","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","copy","-movflags","+faststart","/media/x.h264.mp4"]}
{"path":"/media/old.avi","playable":false,"encoder":"h264_nvenc","copies_audio":false,"argv":["-nostdin","-y","-i","/media/old.avi","-c:v","h264_nvenc","-preset","p5","-rc","vbr","-cq","23","-b:v","0","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/old.h264.mp4"]}
{"path":"/media/film.mkv","playable":false,"encoder":"copy","copies_audio":true,"argv":["-nostdin","-y","-i","/media/film.mkv","-c:v","copy","-c:a","copy","-movflags","+faststart","/media/film.h264.mp4"]}
+99
View File
@@ -0,0 +1,99 @@
/**
* Decide playability and build the ffmpeg call the way the media server did,
* for comparison.
*
* The functions below are the originals, copied out of the transcode service
* verbatim and with their type annotations dropped — the module they live in
* opens a database connection when it loads, which an oracle must not do. The
* surrounding job bookkeeping (Prisma writes, progress parsing, the temporary
* file it renames from) is not copied, because none of it decides anything.
*
* node oracle/compatible.mjs < cases.ndjson
*
* One case per line: {path, video, audio, encoder, output}. Writes one JSON
* verdict per line.
*/
import { extname } from "node:path";
import { createInterface } from "node:readline";
const APPLE_NATIVE_VIDEO = /^(avc1|h264|hvc1|hev1|hevc)/i;
const APPLE_NATIVE_AUDIO = /^(aac|mp4a)/i;
const MP4_CONTAINERS = new Set([".mp4", ".m4v", ".mov"]);
function isAppleCompatible(videoCodec, audioCodec, videoPath) {
if (!videoCodec || !audioCodec || !videoPath) return false;
return (
APPLE_NATIVE_VIDEO.test(videoCodec) &&
APPLE_NATIVE_AUDIO.test(audioCodec) &&
MP4_CONTAINERS.has(extname(videoPath).toLowerCase())
);
}
/**
* The encoder table. The original reads COMPATIBLE_VIDEO_ENCODER here; the
* name is passed in instead, so one run can cover every branch.
*/
function videoEncodePlan(name, device) {
switch ((name || "h264_nvenc").toLowerCase()) {
case "h264_videotoolbox":
return {
preInput: [],
video: ["-c:v", "h264_videotoolbox", "-q:v", "60", "-profile:v", "high", "-pix_fmt", "yuv420p"],
};
case "h264_vaapi":
return {
preInput: ["-init_hw_device", `vaapi=va:${device || "/dev/dri/renderD128"}`, "-filter_hw_device", "va"],
video: ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "23"],
};
case "libx264":
return {
preInput: [],
video: ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"],
};
default:
return {
preInput: [],
video: ["-c:v", "h264_nvenc", "-preset", "p5", "-rc", "vbr", "-cq", "23", "-b:v", "0"],
};
}
}
function verdict(c) {
const vcodec = c.video || "";
const acodec = c.audio || "";
if (isAppleCompatible(vcodec, acodec, c.path)) {
return { path: c.path, playable: true, encoder: null, copies_audio: true, argv: [] };
}
const copyVideo = APPLE_NATIVE_VIDEO.test(vcodec);
const copyAudio = APPLE_NATIVE_AUDIO.test(acodec);
const base = c.path.slice(0, c.path.length - extname(c.path).length);
const out = c.output || `${base}.h264.mp4`;
const plan = copyVideo
? { preInput: [], video: ["-c:v", "copy"] }
: videoEncodePlan(c.encoder, c.vaapi_device);
const encoder = copyVideo ? "copy" : (c.encoder || "h264_nvenc");
const argv = [
"-nostdin",
"-y",
...plan.preInput,
"-i",
c.path,
...plan.video,
...(copyAudio ? ["-c:a", "copy"] : ["-c:a", "aac", "-b:a", "160k", "-ac", "2"]),
"-movflags",
"+faststart",
out,
];
return { path: c.path, playable: false, encoder, copies_audio: copyAudio, argv };
}
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) continue;
process.stdout.write(JSON.stringify(verdict(JSON.parse(line))) + "\n");
}
+14 -1
View File
@@ -19,7 +19,7 @@ type Written = Result<Vec<String>, String>;
type Job = (&'static str, fn() -> Written); type Job = (&'static str, fn() -> Written);
fn main() -> ExitCode { fn main() -> ExitCode {
let jobs: [Job; 15] = [ let jobs: [Job; 16] = [
("tokens", tokens), ("tokens", tokens),
("unescape", unescape), ("unescape", unescape),
("chunkmd", chunkmd), ("chunkmd", chunkmd),
@@ -35,6 +35,7 @@ fn main() -> ExitCode {
("author", author), ("author", author),
("vocab", vocab), ("vocab", vocab),
("mdsplit", mdsplit), ("mdsplit", mdsplit),
("avplan", avplan),
]; ];
for (name, job) in jobs { for (name, job) in jobs {
@@ -217,6 +218,18 @@ fn cueseg() -> Written {
Ok(written) Ok(written)
} }
fn avplan() -> Written {
let dir = parity::golden("avplan");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let cases = parity::dir().join("cases/avplan/cases.ndjson");
let text = std::fs::read_to_string(&cases).map_err(|e| e.to_string())?;
let verdicts = parity::run_node_oracle("compatible.mjs", &text)?;
let path = dir.join("cases.ndjson");
std::fs::write(&path, verdicts).map_err(|e| e.to_string())?;
Ok(vec![path.display().to_string()])
}
fn cuenorm() -> Written { fn cuenorm() -> Written {
let dir = parity::golden("cuenorm"); let dir = parity::golden("cuenorm");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+127
View File
@@ -0,0 +1,127 @@
//! The verdict and the command line must match the media server's exactly.
//!
//! A wrong "it plays" ships a file that opens to a black screen on the device
//! it was converted for; a wrong "it does not" spends forty minutes of GPU
//! time re-encoding something that was already fine. The command line has to
//! agree argument for argument, because a dropped `-pix_fmt` or a `-movflags`
//! in the wrong place is a file that encodes successfully and still will not
//! play.
use serde_json::Value;
/// The encoder the server defaults to when nothing names one.
const DEFAULT_ENCODER: &str = "h264_nvenc";
fn named(name: &str) -> avplan::Encoder {
*avplan::encoder::PREFERRED
.iter()
.find(|encoder| encoder.name() == name)
.unwrap_or_else(|| panic!("{name} is not an encoder"))
}
fn records(text: &str) -> Vec<Value> {
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("valid JSON"))
.collect()
}
fn cases() -> Vec<Value> {
let text = std::fs::read_to_string(parity::dir().join("cases/avplan/cases.ndjson"))
.expect("a readable case file");
records(&text)
}
#[test]
fn every_verdict_matches_the_original() {
let golden = records(&parity::read_golden_text("avplan/cases.ndjson"));
let cases = cases();
assert_eq!(cases.len(), golden.len(), "case count moved — regenerate");
let mut wrong: Vec<String> = Vec::new();
for (case, theirs) in cases.iter().zip(&golden) {
let name = case["name"].as_str().expect("a name");
let path = case["path"].as_str().expect("a path");
assert_eq!(theirs["path"].as_str(), Some(path), "cases out of order");
let streams = avplan::Streams {
video: case["video"].as_str().map(str::to_string),
audio: case["audio"].as_str().map(str::to_string),
container: avplan::container_of(path),
};
let device = case["vaapi_device"]
.as_str()
.unwrap_or(avplan::DEFAULT_VAAPI_DEVICE);
let output = case["output"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| avplan::output_beside(path));
let chosen = named(case["encoder"].as_str().unwrap_or(DEFAULT_ENCODER));
let ours = avplan::plan(path, &streams, Some(chosen), &output, device);
let mut note = |field: &str, ours: String, theirs: &Value| {
wrong.push(format!("{name}.{field}: original {theirs}, rust {ours}"));
};
if Some(ours.playable) != theirs["playable"].as_bool() {
note("playable", ours.playable.to_string(), &theirs["playable"]);
}
if Some(ours.copies_audio) != theirs["copies_audio"].as_bool() {
note(
"copies_audio",
ours.copies_audio.to_string(),
&theirs["copies_audio"],
);
}
let their_encoder = theirs["encoder"].as_str();
let our_encoder = ours.encoder.map(avplan::Encoder::name);
if our_encoder != their_encoder {
note("encoder", format!("{our_encoder:?}"), &theirs["encoder"]);
}
let their_argv: Vec<&str> = theirs["argv"]
.as_array()
.expect("an argument list")
.iter()
.map(|arg| arg.as_str().expect("a string argument"))
.collect();
if ours.argv != their_argv {
note("argv", format!("{:?}", ours.argv), &theirs["argv"]);
}
}
assert!(
wrong.is_empty(),
"{} verdicts differ:\n{}",
wrong.len(),
wrong.join("\n")
);
}
/// Every branch of the encoder table has to be exercised, or the parity test
/// proves nothing about the three encoders nobody's laptop happens to have.
#[test]
fn every_encoder_appears_among_the_cases() {
let named: Vec<String> = cases()
.iter()
.filter_map(|case| case["encoder"].as_str().map(str::to_string))
.collect();
for encoder in avplan::encoder::PREFERRED {
assert!(
named.iter().any(|name| name == encoder.name()),
"no case uses {}",
encoder.name()
);
}
}
/// Catches a golden file drifting away from an original that has moved on.
#[test]
#[cfg(feature = "python")]
fn the_golden_file_still_matches_the_live_original() {
let cases = std::fs::read_to_string(parity::dir().join("cases/avplan/cases.ndjson"))
.expect("a readable case file");
let live = parity::run_node_oracle("compatible.mjs", &cases).expect("the oracle runs");
assert_eq!(live, parity::read_golden_text("avplan/cases.ndjson"));
}