avplan: paths an encoder can actually read, and a pipeline that runs

This commit is contained in:
funkpower
2026-09-19 15:31:17 -04:00
parent ab33ac4693
commit a03dcd400c
8 changed files with 218 additions and 11 deletions
+26
View File
@@ -268,3 +268,29 @@ sheet.
and the contract gives them different numbers. A caller that wants the
Python's behaviour treats both as no; a caller that wants to know which files
it failed on can now find out.
## avplan — separators in a path, and a destination that starts with a dash
**Original:** builds the destination by byte arithmetic — the path, minus as
many bytes as the extension is long, plus `.h264.mp4`. Its extension function
ignores trailing separators while the subtraction does not, so `x.mp4//`
becomes `x.m.h264.mp4`. Duplicate separators are carried through, and a
destination beginning with `-` is emitted bare.
**Rust:** takes the path apart properly. `film.mp4/.` is a directory and has
no extension rather than an `mp4` one; `/talk.webm` becomes `/talk.h264.mp4`
rather than `//talk.h264.mp4`; `x.mp4//` becomes `x.h264.mp4`. A destination
beginning with `-` is written `./-name`, which is the same file and cannot be
read as an option.
**Why this is acceptable:** every one of these is a path the original gets
wrong. A double leading separator is left to the system to interpret and some
systems interpret it as something else; `x.m.h264.mp4` names a file nobody
asked for; a directory reported as a playable MP4 is a wrong answer outright;
and a command line whose destination is read as an option fails on a file
that was otherwise fine. The original never met any of them because its paths
were always joined onto an absolute root.
**Measured:** 32 cases, including a file at the root, a name beginning with a
dash, a name that is only a dot, a non-ASCII name and an extension that is
only a dot. Ordinary paths — the overwhelming majority — are byte-identical.
+8
View File
@@ -22,3 +22,11 @@
{"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"}
{"name":"at-the-filesystem-root","path":"/talk.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"a-name-beginning-with-a-dash","path":"/media/-clip.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"a-name-that-is-only-a-dot","path":"/media/.hidden.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"an-upper-case-codec","path":"/media/x.mkv","video":"H264","audio":"AAC","encoder":"libx264"}
{"name":"a-codec-with-a-profile-suffix","path":"/media/x.mp4","video":"hev1.2.4.L120.B0","audio":"mp4a.40.5","encoder":"libx264"}
{"name":"a-non-ascii-name","path":"/media/Καλημέρα.webm","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"an-extension-that-is-only-a-dot","path":"/media/talk.","video":"vp9","audio":"opus","encoder":"libx264"}
{"name":"a-relative-name-beginning-with-a-dash","path":"-clip.webm","video":"vp9","audio":"opus","encoder":"libx264"}
+8
View File
@@ -22,3 +22,11 @@
{"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"]}
{"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/-clip.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/-clip.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/-clip.h264.mp4"]}
{"path":"/media/.hidden.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/.hidden.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/.hidden.h264.mp4"]}
{"path":"/media/x.mkv","playable":false,"encoder":"copy","copies_audio":true,"argv":["-nostdin","-y","-i","/media/x.mkv","-c:v","copy","-c:a","copy","-movflags","+faststart","/media/x.h264.mp4"]}
{"path":"/media/x.mp4","playable":true,"encoder":null,"copies_audio":true,"argv":[]}
{"path":"/media/Καλημέρα.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/Καλημέρα.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/Καλημέρα.h264.mp4"]}
{"path":"/media/talk.","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","/media/talk.","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","/media/talk.h264.mp4"]}
{"path":"-clip.webm","playable":false,"encoder":"libx264","copies_audio":false,"argv":["-nostdin","-y","-i","-clip.webm","-c:v","libx264","-preset","veryfast","-crf","23","-pix_fmt","yuv420p","-c:a","aac","-b:a","160k","-ac","2","-movflags","+faststart","-clip.h264.mp4"]}
+37
View File
@@ -12,6 +12,12 @@ use serde_json::Value;
/// The encoder the server defaults to when nothing names one.
const DEFAULT_ENCODER: &str = "h264_nvenc";
/// Answers that are meant to differ, each argued for in `KNOWN.md`.
///
/// The original emits a destination beginning with a dash bare, and an
/// encoder reads it as an option rather than a file.
const DIVERGENT: &[(&str, &str)] = &[("a-relative-name-beginning-with-a-dash", "argv")];
fn named(name: &str) -> avplan::Encoder {
*avplan::encoder::PREFERRED
.iter()
@@ -60,6 +66,9 @@ fn every_verdict_matches_the_original() {
let ours = avplan::plan(path, &streams, Some(chosen), &output, device);
let mut note = |field: &str, ours: String, theirs: &Value| {
if DIVERGENT.contains(&(name, field)) {
return;
}
wrong.push(format!("{name}.{field}: original {theirs}, rust {ours}"));
};
@@ -99,6 +108,34 @@ fn every_verdict_matches_the_original() {
);
}
/// A divergence that has quietly gone away is a note in `KNOWN.md` that is no
/// longer true, which is worth knowing about.
#[test]
fn the_documented_divergence_is_exactly_what_was_argued_for() {
let case = cases()
.into_iter()
.find(|case| case["name"] == "a-relative-name-beginning-with-a-dash")
.expect("the case exists");
let path = case["path"].as_str().expect("a path");
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 ours = avplan::plan(
path,
&streams,
Some(named("libx264")),
&avplan::output_beside(path),
avplan::DEFAULT_VAAPI_DEVICE,
);
assert_eq!(
ours.argv.last().map(String::as_str),
Some("./-clip.h264.mp4"),
"the destination is no longer named so an encoder reads it as a file"
);
}
/// 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]