mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
avplan: whether a recording plays, and the one call that fixes it
This commit is contained in:
Generated
+14
@@ -73,6 +73,19 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "avplan"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
"corpus-core",
|
||||
"regex",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -693,6 +706,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
name = "parity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"avplan",
|
||||
"bibclean",
|
||||
"booksniff",
|
||||
"chunkmd",
|
||||
|
||||
@@ -23,6 +23,7 @@ cueseg = { path = "crates/cueseg", version = "0.1.0" }
|
||||
rrf = { path = "crates/rrf", version = "0.1.0" }
|
||||
cuepack = { path = "crates/cuepack", 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" }
|
||||
emb = { path = "crates/emb", version = "0.1.0" }
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| [`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 |
|
||||
| [`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
|
||||
examples, options and exit codes.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ cuepack.workspace = true
|
||||
cueseg.workspace = true
|
||||
mdscrub.workspace = true
|
||||
mdsplit.workspace = true
|
||||
avplan.workspace = true
|
||||
pdftriage.workspace = true
|
||||
rrf.workspace = true
|
||||
corpus-core.workspace = true
|
||||
|
||||
@@ -156,3 +156,48 @@ exactly.
|
||||
|
||||
**Measured:** checked against the oracle for all of those combinations; only
|
||||
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
@@ -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
|
||||
```
|
||||
|
||||
One oracle is JavaScript rather than Python, because the code it compares
|
||||
against is. Its three functions are copied into `oracle/cuenorm.mjs` verbatim,
|
||||
since the module they live in opens a database connection when it loads.
|
||||
Two oracles are JavaScript rather than Python, because the code they compare
|
||||
against is. Their functions are copied into `oracle/cuenorm.mjs` and
|
||||
`oracle/compatible.mjs` verbatim, since the modules they live in open a
|
||||
database connection when they load.
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"]}
|
||||
@@ -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
@@ -19,7 +19,7 @@ type Written = Result<Vec<String>, String>;
|
||||
type Job = (&'static str, fn() -> Written);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let jobs: [Job; 15] = [
|
||||
let jobs: [Job; 16] = [
|
||||
("tokens", tokens),
|
||||
("unescape", unescape),
|
||||
("chunkmd", chunkmd),
|
||||
@@ -35,6 +35,7 @@ fn main() -> ExitCode {
|
||||
("author", author),
|
||||
("vocab", vocab),
|
||||
("mdsplit", mdsplit),
|
||||
("avplan", avplan),
|
||||
];
|
||||
|
||||
for (name, job) in jobs {
|
||||
@@ -217,6 +218,18 @@ fn cueseg() -> 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 {
|
||||
let dir = parity::golden("cuenorm");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user