weavesignal/crates/desk/tests/vmaf.rs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

250 lines
9.8 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! The picture's quality at each rung (ADR-0027), measured as bench 01 measured it: the desk's
//! own encoder settings and CMAF writer, then the segments decoded to raw frames (never scored
//! as a container: bench 01's trap) and scored with VMAF against the same frames before
//! encoding. Ignored: it needs Xiph's talking-head clips in `target/clips/` (bench 01's
//! `KristenAndSara_1280x720_60.y4m` and `FourPeople_1280x720_60.y4m`) and FFmpeg with libvmaf
//! (the desk image has both tools). `just test-vmaf`; with `WEAVESIGNAL_DESK_GPU=1`, NVENC too.
use std::path::{Path, PathBuf};
use std::process::Command;
use gstreamer as gst;
use gstreamer::prelude::*;
use weavesignal_desk::config::Rung;
use weavesignal_desk::video::{Encoder, encoder_chain};
fn ffmpeg(args: &[&str]) {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y"])
.args(args)
.output()
.expect("ffmpeg runs (the desk image has it)");
assert!(
out.status.success(),
"ffmpeg {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// The desk's encode of `canvas_yuv` (854x480 at 20 fps, Y4M) for `rung`, written as
/// CMAF HLS into `dir`; the segments' bytes.
fn encode(canvas_yuv: &Path, rung: &Rung, enc: Encoder, dir: &Path) -> u64 {
let canvas = Rung::PRESETS[0];
std::fs::create_dir_all(dir).unwrap();
let p = gst::Pipeline::new();
let src = gst::ElementFactory::make("filesrc")
.property("location", canvas_yuv.to_str().unwrap())
.build()
.unwrap();
// Y4M, not raw: GStreamer pads an 854-pixel row to 856, FFmpeg's raw frames do not.
let parse = gst::ElementFactory::make("y4mdec").build().unwrap();
let mut chain = vec![src, parse];
chain.extend(encoder_chain(rung, &canvas, enc, 2).unwrap());
// The leaky queue at the head of the chain is for live use; here every frame counts.
chain[2].set_property_from_str("leaky", "no");
let at = |f: &str| dir.join(f).to_string_lossy().into_owned();
chain.push(
gst::ElementFactory::make("hlscmafsink")
.property("sync", false)
.property("target-duration", 2u32)
.property("playlist-length", 0u32)
.property("max-files", 0u32)
.property("location", at("seg%05d.m4s"))
.property("init-location", at("init%05d.mp4"))
.property("playlist-location", at("index.m3u8"))
.build()
.unwrap(),
);
p.add_many(&chain).unwrap();
gst::Element::link_many(&chain).unwrap();
p.set_state(gst::State::Playing).unwrap();
let msg = p
.bus()
.unwrap()
.timed_pop_filtered(
gst::ClockTime::from_seconds(300),
&[gst::MessageType::Eos, gst::MessageType::Error],
)
.expect("the encode finished");
p.set_state(gst::State::Null).unwrap();
assert!(matches!(msg.view(), gst::MessageView::Eos(_)), "{msg:?}");
std::fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "m4s"))
.map(|e| e.metadata().unwrap().len())
.sum()
}
/// The playlist's init section and segments joined, as a player fetches them.
fn join(dir: &Path) -> PathBuf {
let playlist = std::fs::read_to_string(dir.join("index.m3u8")).unwrap();
let mut bytes = Vec::new();
for line in playlist.lines() {
if let Some(init) = line.strip_prefix("#EXT-X-MAP:URI=\"") {
bytes.extend(std::fs::read(dir.join(init.trim_end_matches('"'))).unwrap());
} else if !line.starts_with('#') && !line.is_empty() {
bytes.extend(std::fs::read(dir.join(line)).unwrap());
}
}
let out = dir.join("joined.mp4");
std::fs::write(&out, bytes).unwrap();
out
}
/// The frames the encoder sees for `rung`, before encoding, as Y4M.
fn reference_for(canvas_y4m: &Path, r: &Rung, out: &Path) {
let line = format!(
"filesrc location={} ! y4mdec ! videoscale ! video/x-raw,width={},height={} ! videorate drop-only=true ! video/x-raw,framerate={}/1 ! videoconvert ! video/x-raw,format=I420 ! y4menc ! filesink location={}",
canvas_y4m.display(),
r.width,
r.height,
r.fps,
out.display()
);
let p = gst::parse::launch(&line).unwrap();
p.set_state(gst::State::Playing).unwrap();
let msg = p
.bus()
.unwrap()
.timed_pop_filtered(
gst::ClockTime::from_seconds(120),
&[gst::MessageType::Eos, gst::MessageType::Error],
)
.unwrap();
p.set_state(gst::State::Null).unwrap();
assert!(matches!(msg.view(), gst::MessageView::Eos(_)), "{msg:?}");
}
fn vmaf(dist: &Path, reference: &Path, r: &Rung, log: &Path) -> f64 {
let size = format!("{}x{}", r.width, r.height);
let fps = r.fps.to_string();
let raw = |p: &Path| -> Vec<String> {
[
"-f", "rawvideo", "-pix_fmt", "yuv420p", "-s", &size, "-r", &fps, "-i",
]
.iter()
.map(|s| s.to_string())
.chain([p.to_string_lossy().into_owned()])
.collect()
};
let mut args = raw(dist);
args.extend(["-i".to_owned(), reference.to_string_lossy().into_owned()]);
args.extend(
[
"-lavfi",
&format!(
"[0:v][1:v]libvmaf=log_fmt=json:log_path={}:n_threads=8",
log.display()
),
"-f",
"null",
"-",
]
.iter()
.map(|s| s.to_string()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
ffmpeg(&refs);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(log).unwrap()).unwrap();
v["pooled_metrics"]["vmaf"]["mean"].as_f64().unwrap()
}
#[test]
#[ignore = "needs Xiph's clips in target/clips and FFmpeg with libvmaf: just test-vmaf"]
fn vmaf_at_each_rung() {
gst::init().unwrap();
let clips = PathBuf::from(
std::env::var("WEAVESIGNAL_CLIPS").unwrap_or_else(|_| "target/clips".to_owned()),
);
let out = std::env::temp_dir().join(format!("weavesignal-vmaf-{}", std::process::id()));
std::fs::create_dir_all(&out).unwrap();
let mut encoders = vec![Encoder::Svt];
if Encoder::nvenc_present() {
encoders.insert(0, Encoder::Nvenc);
}
let canvas = Rung::PRESETS[0];
let mut rows = Vec::new();
for clip in ["KristenAndSara", "FourPeople"] {
let src = clips.join(format!("{clip}_1280x720_60.y4m"));
assert!(
src.is_file(),
"{} is missing (see this test's notes)",
src.display()
);
// What the vision mixer hands the encoders: the camera at the canvas's size and rate.
let canvas_yuv = out.join(format!("{clip}-canvas.y4m"));
ffmpeg(&[
"-i",
src.to_str().unwrap(),
"-vf",
&format!(
"fps={},scale={}:{}:flags=lanczos",
canvas.fps, canvas.width, canvas.height
),
"-pix_fmt",
"yuv420p",
canvas_yuv.to_str().unwrap(),
]);
for r in Rung::PRESETS {
// The reference at the rung's own size and rate, made from the same frames by the
// same scaler and frame-rate converter as the encode, so the frames line up.
let reference = out.join(format!("{clip}-{}-ref.y4m", r.name));
reference_for(&canvas_yuv, &r, &reference);
for &enc in &encoders {
let dir = out.join(format!("{clip}-{}-{enc:?}", r.name));
let bytes = encode(&canvas_yuv, &r, enc, &dir);
// Decoded to raw frames first, then scored.
let dist = dir.join("decoded.yuv");
ffmpeg(&[
"-i",
join(&dir).to_str().unwrap(),
// Every decoded frame once: no frames repeated to fill a rate.
"-fps_mode",
"passthrough",
"-pix_fmt",
"yuv420p",
"-f",
"rawvideo",
dist.to_str().unwrap(),
]);
let frame = (r.width * r.height * 3 / 2) as u64;
// Y4M: a 44-ish byte header, and "FRAME\n" before each frame.
let frames_ref = std::fs::metadata(&reference).unwrap().len() / (frame + 6);
let frames_dist = std::fs::metadata(&dist).unwrap().len() / frame;
let score = vmaf(&dist, &reference, &r, &dir.join("vmaf.json"));
let seconds = frames_ref as f64 / f64::from(r.fps);
let kbps = bytes as f64 * 8.0 / seconds / 1000.0;
let row = serde_json::json!({
"clip": clip, "rung": r.name, "encoder": enc, "target_kbps": r.kbps,
"kbps": (kbps * 10.0).round() / 10.0, "vmaf": (score * 100.0).round() / 100.0,
"frames": frames_dist, "frames_equal": frames_ref == frames_dist,
});
println!("{row}");
rows.push(row);
}
}
}
let report = serde_json::json!({ "rows": rows });
if let Ok(path) = std::env::var("WEAVESIGNAL_ROOM_REPORT") {
let _ = std::fs::write(path, format!("{report:#}\n"));
}
let _ = std::fs::remove_dir_all(&out);
// The default rung keeps bench 01's quality: NVENC's 94.3 to 96.4 at 480p20, 600 kbit/s;
// SVT-AV1 a couple of points behind.
for row in &rows {
if row["rung"] == "480p20" {
let floor = if row["encoder"] == "nvenc" {
93.0
} else {
90.0
};
let v = row["vmaf"].as_f64().unwrap();
assert!(v >= floor, "{row}");
}
}
}