From da992c139a58d503785d03e3f2ccd1f4002fb370 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Tue, 29 Sep 2026 17:59:14 +0200 Subject: [PATCH] feat: add encoder tuning harness for 4K60 fractal videos make_clips.sh renders lossless FFV1 4K60 test clips through the Makefile's colour conversion; sweep.sh encodes them with a list of ffmpeg configs and scores each with VMAF 4K (mean/p1/min), VMAF-NEG, CAMBI banding and PSNR-Y. Co-Authored-By: Claude Opus 5.5 --- tools/encode/.gitignore | 3 ++ tools/encode/configs/speed.txt | 8 ++++ tools/encode/make_clips.sh | 66 ++++++++++++++++++++++++++ tools/encode/sweep.sh | 87 ++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 tools/encode/.gitignore create mode 100644 tools/encode/configs/speed.txt create mode 100755 tools/encode/make_clips.sh create mode 100755 tools/encode/sweep.sh diff --git a/tools/encode/.gitignore b/tools/encode/.gitignore new file mode 100644 index 0000000..8031afc --- /dev/null +++ b/tools/encode/.gitignore @@ -0,0 +1,3 @@ +clips/ +work/ +results/ diff --git a/tools/encode/configs/speed.txt b/tools/encode/configs/speed.txt new file mode 100644 index 0000000..f43a07c --- /dev/null +++ b/tools/encode/configs/speed.txt @@ -0,0 +1,8 @@ +# Pass 1: which presets fit the 3-5 fps budget at 4K on this machine. +x265-fast | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset fast -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1 +x265-medium | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset medium -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1 +x265-slow | -pix_fmt yuv420p10le -c:v libx265 -profile:v main10 -preset slow -crf 18 -x265-params log-level=error:aq-mode=3:no-sao=1 +av1-p5 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 5 -crf 26 -svtav1-params film-grain=0 +av1-p6 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 6 -crf 26 -svtav1-params film-grain=0 +av1-p7 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 7 -crf 26 -svtav1-params film-grain=0 +av1-p8 | -pix_fmt yuv420p10le -c:v libsvtav1 -preset 8 -crf 26 -svtav1-params film-grain=0 diff --git a/tools/encode/make_clips.sh b/tools/encode/make_clips.sh new file mode 100755 index 0000000..c6a2138 --- /dev/null +++ b/tools/encode/make_clips.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Render the lossless 4K60 test clips that sweep.sh encodes. +# +# Each clip goes through the same RGBA -> bt709 tv-range 10-bit conversion as +# the Makefile, then into lossless FFV1. The clips are therefore exactly what the +# encoders see, so VMAF against them measures the encoder alone. +# +# tools/encode/make_clips.sh [--size WxH] [--seconds S] [--force] [clip...] +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +out="$here/clips" +size=3840x2160 +seconds=4 +fps=60 +force=0 +only=() + +while [[ $# -gt 0 ]]; do + case $1 in + --size) size=$2; shift 2 ;; + --seconds) seconds=$2; shift 2 ;; + --force) force=1; shift ;; + -h|--help) sed -n '2,9p' "$0"; exit 0 ;; + *) only+=("$1"); shift ;; + esac +done + +# Seahorse valley, minibrot at 3.7e-41 (tools/deep-zoom/LOCATIONS.md). +SH_RE=-0.77568376800905379745347652613832924487504096622022 +SH_IM=0.13646736829469012473375311880735014411233827361594 + +# name | mandelbrot flags. Zoom speeds bracket a typical 60 s dive (~0.7 decade/s). +clips=( + # Worst case: dense filaments, 1.5 decades/s of scaling motion. + "filaments|--view=$SH_RE,$SH_IM,1e-8 --to-view=$SH_RE,$SH_IM,3e-15 --linear" + # Smooth concentric bands closing in on the minibrot: banding/blocking risk. + "bands|--view=$SH_RE,$SH_IM,1e-38 --to-view=$SH_RE,$SH_IM,1.1e-40 --linear" + # Embedded Julia set, slow drift: fine static texture, psy/detail retention. + "julia|--view=$SH_RE,$SH_IM,2e-30 --to-view=$SH_RE,$SH_IM,1e-30 --linear" +) + +[[ -x $root/target/release/mandelbrot ]] || cargo build --release --manifest-path "$root/Cargo.toml" +mkdir -p "$out" +w=${size%x*} +h=${size#*x} + +for entry in "${clips[@]}"; do + name=${entry%%|*} + flags=${entry#*|} + if [[ ${#only[@]} -gt 0 && ! " ${only[*]} " =~ " $name " ]]; then continue; fi + dst="$out/$name.mkv" + if [[ -f $dst && $force = 0 ]]; then echo "skip $name (exists, --force to redo)"; continue; fi + echo "render $name ($size, ${seconds}s @ ${fps}fps)" + # shellcheck disable=SC2086 + "$root/target/release/mandelbrot" --headless --antialias $flags \ + --width "$w" --height "$h" --fps "$fps" --duration "$seconds" --export-path - | + ffmpeg -hide_banner -loglevel error -y \ + -f rawvideo -pix_fmt rgba -s "$size" -framerate "$fps" -i - \ + -vf "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int+bitexact,format=yuv420p10le" \ + -c:v ffv1 -level 3 -slices 16 -g 1 \ + -colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv \ + "$dst.tmp.mkv" + mv "$dst.tmp.mkv" "$dst" +done diff --git a/tools/encode/sweep.sh b/tools/encode/sweep.sh new file mode 100755 index 0000000..1d1984a --- /dev/null +++ b/tools/encode/sweep.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Encode every test clip with every config, then score each encode. +# +# tools/encode/sweep.sh [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE... +# +# A config line is `name | ffmpeg output args` (blank lines and # comments are +# skipped). The args go between `-i clip.mkv` and the output file, e.g. +# x265-crf18 | -c:v libx265 -preset medium -crf 18 -x265-params aq-mode=3 +# Each row of results/.tsv has encode fps, bitrate, VMAF 4K (mean, 1st +# percentile, min), VMAF-NEG mean, CAMBI banding (mean, max) and PSNR-Y. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +clipdir="$here/clips" +model=/usr/share/model +export SVT_LOG=1 # errors only +clips="" +keep=0 +tag=$(date +%Y%m%d-%H%M%S) +configs=() + +while [[ $# -gt 0 ]]; do + case $1 in + --clips) clips=$2; shift 2 ;; + --keep) keep=1; shift ;; + --tag) tag=$2; shift 2 ;; + -h|--help) sed -n '2,11p' "$0"; exit 0 ;; + *) configs+=("$1"); shift ;; + esac +done +[[ ${#configs[@]} -gt 0 ]] || { echo "usage: $0 [--clips a,b] [--keep] [--tag NAME] CONFIG_FILE..." >&2; exit 1; } + +if [[ -z $clips ]]; then + clips=$(cd "$clipdir" && ls ./*.mkv | sed 's|^\./||; s|\.mkv$||' | paste -sd,) +fi +[[ -n $clips ]] || { echo "no clips in $clipdir: run make_clips.sh first" >&2; exit 1; } + +mkdir -p "$here/results" "$here/work" +tsv="$here/results/$tag.tsv" +[[ -f $tsv ]] || printf 'clip\tconfig\tfps\tmbps\tvmaf\tvmaf_p1\tvmaf_min\tvmaf_neg\tcambi\tcambi_max\tpsnr_y\n' > "$tsv" + +IFS=, read -ra clip_list <<< "$clips" +for cfg in "${configs[@]}"; do + while IFS= read -r line || [[ -n $line ]]; do + [[ $line =~ ^[[:space:]]*(#|$) ]] && continue + name=$(sed 's/[[:space:]]*|.*//' <<< "$line") + args=$(sed 's/^[^|]*|[[:space:]]*//' <<< "$line") + for clip in "${clip_list[@]}"; do + ref="$clipdir/$clip.mkv" + enc="$here/work/$clip--$name.mp4" + log="$here/work/$clip--$name.json" + frames=$(ffprobe -v error -count_packets -select_streams v:0 \ + -show_entries stream=nb_read_packets -of csv=p=0 "$ref") + printf '%-10s %-40s ' "$clip" "$name" + + t0=$(date +%s.%N) + # shellcheck disable=SC2086 + ffmpeg -hide_banner -loglevel error -y -i "$ref" $args -an "$enc" < /dev/null + t1=$(date +%s.%N) + + # 4K model for both VMAF variants; CAMBI flags banding, which VMAF + # barely sees and which is the main risk on smooth fractal gradients. + ffmpeg -hide_banner -loglevel error -i "$enc" -i "$ref" -lavfi \ + "[0:v]setpts=PTS-STARTPTS[d];[1:v]setpts=PTS-STARTPTS[r];[d][r]libvmaf=log_fmt=json:log_path=$log:n_threads=$(nproc):n_subsample=2:model='path=$model/vmaf_4k_v0.6.1.json\\:name=vmaf|path=$model/vmaf_4k_v0.6.1neg.json\\:name=neg':feature='name=cambi|name=psnr'" \ + -f null - < /dev/null + + row=$(python3 - "$log" "$enc" "$frames" "$t0" "$t1" <<'PY' +import json, os, sys +log, enc, frames, t0, t1 = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]) +fr = json.load(open(log))["frames"] +col = lambda k: sorted(f["metrics"][k] for f in fr) +v = col("vmaf") +p1 = v[max(0, int(0.01 * len(v)) - 1)] if len(v) >= 100 else v[0] +mean = lambda xs: sum(xs) / len(xs) +cambi = col("cambi") +fps = frames / (t1 - t0) +mbps = os.path.getsize(enc) * 8 / (frames / 60) / 1e6 +print(f"{fps:.2f}\t{mbps:.1f}\t{mean(v):.2f}\t{p1:.2f}\t{v[0]:.2f}\t{mean(col('neg')):.2f}\t{mean(cambi):.2f}\t{cambi[-1]:.2f}\t{mean(col('psnr_y')):.2f}") +PY +) + printf '%s\t%s\t%s\n' "$clip" "$name" "$row" >> "$tsv" + awk -F'\t' '{printf "%6s fps %7s Mb/s vmaf %s p1 %s min %s neg %s cambi %s/%s psnr %s\n",$1,$2,$3,$4,$5,$6,$7,$8,$9}' <<< "$row" + [[ $keep = 1 ]] || rm -f "$enc" + done + done < "$cfg" +done +echo "results: $tsv"