From 49f12c9718b7632852808ac48df8e541648d11c7 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sat, 19 Sep 2026 15:07:33 -0400 Subject: [PATCH] avplan: whether a recording plays, and the one call that fixes it --- Cargo.lock | 14 ++ Cargo.toml | 1 + README.md | 1 + crates/avplan/Cargo.toml | 20 ++ crates/avplan/README.md | 163 +++++++++++++ crates/avplan/src/encoder.rs | 224 ++++++++++++++++++ crates/avplan/src/lib.rs | 378 ++++++++++++++++++++++++++++++ crates/avplan/src/main.rs | 231 ++++++++++++++++++ parity/Cargo.toml | 1 + parity/KNOWN.md | 45 ++++ parity/SOURCE.md | 7 +- parity/cases/avplan/cases.ndjson | 24 ++ parity/golden/avplan/cases.ndjson | 24 ++ parity/oracle/compatible.mjs | 99 ++++++++ parity/src/bin/regen.rs | 15 +- parity/tests/avplan.rs | 127 ++++++++++ 16 files changed, 1370 insertions(+), 4 deletions(-) create mode 100644 crates/avplan/Cargo.toml create mode 100644 crates/avplan/README.md create mode 100644 crates/avplan/src/encoder.rs create mode 100644 crates/avplan/src/lib.rs create mode 100644 crates/avplan/src/main.rs create mode 100644 parity/cases/avplan/cases.ndjson create mode 100644 parity/golden/avplan/cases.ndjson create mode 100644 parity/oracle/compatible.mjs create mode 100644 parity/tests/avplan.rs diff --git a/Cargo.lock b/Cargo.lock index 07d733e..50b480a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index a2d12cd..7f61e4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 4294198..4b67f70 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/avplan/Cargo.toml b/crates/avplan/Cargo.toml new file mode 100644 index 0000000..834ca9b --- /dev/null +++ b/crates/avplan/Cargo.toml @@ -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 diff --git a/crates/avplan/README.md b/crates/avplan/README.md new file mode 100644 index 0000000..c0c3f8a --- /dev/null +++ b/crates/avplan/README.md @@ -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 ` | Where the converted copy goes. Defaults to `.h264.mp4` beside the original | +| `--encoder ` | Use this encoder rather than the best one installed | +| `--vaapi-device ` | 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. diff --git a/crates/avplan/src/encoder.rs b/crates/avplan/src/encoder.rs new file mode 100644 index 0000000..7babf18 --- /dev/null +++ b/crates/avplan/src/encoder.rs @@ -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 = + 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 { + 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 { + 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 { + 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"]); + } +} diff --git a/crates/avplan/src/lib.rs b/crates/avplan/src/lib.rs new file mode 100644 index 0000000..9681e84 --- /dev/null +++ b/crates/avplan/src/lib.rs @@ -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 = + 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 = + 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, + /// The audio codec. + pub audio: Option, + /// The file's extension, lowercased, without the dot. + pub container: Option, +} + +/// 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, + /// Which encoder the picture would go through, or `copy`. + pub encoder: Option, + /// 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, +} + +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 { + 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, + 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)); + } +} diff --git a/crates/avplan/src/main.rs b/crates/avplan/src/main.rs new file mode 100644 index 0000000..9e2515b --- /dev/null +++ b/crates/avplan/src/main.rs @@ -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, + + /// 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, +} + +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::>() + .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 { + 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 { + 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 { + 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"); + } +} diff --git a/parity/Cargo.toml b/parity/Cargo.toml index 0ecdf15..b61cf50 100644 --- a/parity/Cargo.toml +++ b/parity/Cargo.toml @@ -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 diff --git a/parity/KNOWN.md b/parity/KNOWN.md index 175d7af..85d8f27 100644 --- a/parity/KNOWN.md +++ b/parity/KNOWN.md @@ -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 `.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. diff --git a/parity/SOURCE.md b/parity/SOURCE.md index af24b0a..4d76290 100644 --- a/parity/SOURCE.md +++ b/parity/SOURCE.md @@ -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. diff --git a/parity/cases/avplan/cases.ndjson b/parity/cases/avplan/cases.ndjson new file mode 100644 index 0000000..b41f34a --- /dev/null +++ b/parity/cases/avplan/cases.ndjson @@ -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"} diff --git a/parity/golden/avplan/cases.ndjson b/parity/golden/avplan/cases.ndjson new file mode 100644 index 0000000..007c4f1 --- /dev/null +++ b/parity/golden/avplan/cases.ndjson @@ -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"]} diff --git a/parity/oracle/compatible.mjs b/parity/oracle/compatible.mjs new file mode 100644 index 0000000..fd153e3 --- /dev/null +++ b/parity/oracle/compatible.mjs @@ -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"); +} diff --git a/parity/src/bin/regen.rs b/parity/src/bin/regen.rs index 370465d..a70abb8 100644 --- a/parity/src/bin/regen.rs +++ b/parity/src/bin/regen.rs @@ -19,7 +19,7 @@ type Written = Result, 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())?; diff --git a/parity/tests/avplan.rs b/parity/tests/avplan.rs new file mode 100644 index 0000000..9b4796a --- /dev/null +++ b/parity/tests/avplan.rs @@ -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 { + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("valid JSON")) + .collect() +} + +fn cases() -> Vec { + 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 = 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 = 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")); +}