Skip to content

Commit 8aac2ee

Browse files
committed
Heavy tests: FFmpeg's FATE samples and benchmarks, with a baseline
`npm run test:heavy` decodes every sample of FFmpeg's FATE suite (~2.5k files, rsynced to .cache/fate-suite or FATE_SUITE=<dir>) with ffmpeg.wasm in Chromium: first video and audio stream, at most 5 s, to framemd5. Each file gets a status (ok / error / crash / timeout / hang) and an output hash, and the run is compared with tests/heavy/baseline/fate-{st,mt}.json: files that stop decoding are regressions and fail the run, files that start decoding or change output are listed. --update rewrites the baseline, --native also runs native ffmpeg and lists what only it decodes. `npm run bench` times common jobs (h264 decode, x264, vp9, scale, remux, aac/mp3/opus) on generated input with both cores, optionally against native ffmpeg, and writes tests/heavy/results/bench.json. CI runs both in a `heavy` job nightly, on workflow_dispatch, or on pull requests labelled `heavy`, reusing the cores the build jobs produced.
1 parent d1e1b7a commit 8aac2ee

7 files changed

Lines changed: 389 additions & 1 deletion

File tree

‎.github/workflows/CI.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ on:
55
branches:
66
- main
77
pull_request:
8+
types: [opened, synchronize, reopened, labeled]
9+
schedule:
10+
- cron: "0 6 * * *"
11+
workflow_dispatch:
812

913
jobs:
1014
build-core:
@@ -108,3 +112,39 @@ jobs:
108112
- run: pnpm install --frozen-lockfile
109113
- run: pnpm test:install
110114
- run: pnpm test --project browser-st --project browser-mt
115+
heavy:
116+
# FATE samples + benchmarks: nightly, on demand, or with the "heavy" PR label.
117+
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'heavy')
118+
runs-on: ubuntu-latest
119+
needs: [build-core, build-core-mt]
120+
steps:
121+
- uses: actions/checkout@v4
122+
- uses: actions/download-artifact@v4
123+
with:
124+
name: ffmpeg-core
125+
path: packages/core/dist
126+
- uses: actions/download-artifact@v4
127+
with:
128+
name: ffmpeg-core-mt
129+
path: packages/core-mt/dist
130+
- uses: pnpm/action-setup@v6
131+
- uses: actions/setup-node@v4
132+
with:
133+
node-version: 24
134+
cache: pnpm
135+
- uses: actions/cache@v4
136+
with:
137+
path: .cache/fate-suite
138+
key: fate-suite-v1
139+
- run: pnpm install --frozen-lockfile
140+
- run: pnpm test:install
141+
- name: FATE samples
142+
run: pnpm test:heavy
143+
- name: Benchmarks
144+
if: always()
145+
run: pnpm bench
146+
- uses: actions/upload-artifact@v4
147+
if: always()
148+
with:
149+
name: heavy-results
150+
path: tests/heavy/results

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ dist
88

99
# Local Netlify folder
1010
.netlify
11+
.cache
12+
tests/heavy/results

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"postinstall": "pnpm build",
1515
"pretest": "pnpm build",
1616
"test": "vitest run",
17-
"test:install": "playwright install --with-deps chromium"
17+
"test:install": "playwright install --with-deps chromium",
18+
"test:heavy": "node tests/heavy/fate.mjs",
19+
"bench": "node tests/heavy/bench.mjs"
1820
},
1921
"devDependencies": {
2022
"@ffmpeg/ffmpeg": "workspace:*",

‎tests/heavy/bench.mjs‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Times common jobs with @ffmpeg/core and @ffmpeg/core-mt in Chromium (and
2+
// native ffmpeg with --native). Inputs are generated, so nothing is downloaded.
3+
//
4+
// pnpm bench
5+
// pnpm bench --runs=5 --only=x264 --type=st --native
6+
//
7+
// Prints a table and writes tests/heavy/results/bench.json.
8+
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
9+
import { spawnSync } from "node:child_process";
10+
import { tmpdir, cpus } from "node:os";
11+
import { join } from "node:path";
12+
import { chromium } from "playwright";
13+
import { startServer, root } from "../helpers/server.js";
14+
15+
const args = Object.fromEntries(
16+
process.argv.slice(2).map((a) => {
17+
const [key, value = true] = a.replace(/^--/, "").split("=");
18+
return [key, value];
19+
})
20+
);
21+
const runs = Number(args.runs ?? 3);
22+
23+
const video = (size) => ["-f", "lavfi", "-i", `testsrc2=s=${size}:r=30:d=5`];
24+
const h264 = (size) => [...video(size), "-c:v", "libx264", "-preset", "ultrafast", "-f", "mp4", "/in"];
25+
const wav = ["-f", "lavfi", "-i", "sine=f=440:d=30", "-ac", "2", "-f", "wav", "/in"];
26+
27+
// name, untimed setup writing /in, timed command reading /in
28+
const JOBS = [
29+
["decode h264 1080p 5s", h264("1920x1080"), ["-i", "/in", "-f", "null", "-"]],
30+
["x264 veryfast 720p 5s", h264("1280x720"), ["-i", "/in", "-c:v", "libx264", "-preset", "veryfast", "-f", "mp4", "/out"]],
31+
["vp9 realtime 480p 5s", h264("854x480"), ["-i", "/in", "-c:v", "libvpx-vp9", "-deadline", "realtime", "-cpu-used", "8", "-f", "webm", "/out"]],
32+
["scale 1080p to 480p", h264("1920x1080"), ["-i", "/in", "-vf", "scale=-2:480", "-f", "null", "-"]],
33+
["remux mp4 to mkv", h264("1920x1080"), ["-i", "/in", "-c", "copy", "-f", "matroska", "/out"]],
34+
["aac encode 30s", wav, ["-i", "/in", "-c:a", "aac", "-f", "ipod", "/out"]],
35+
["mp3 encode 30s", wav, ["-i", "/in", "-f", "mp3", "/out"]],
36+
["opus encode 30s", wav, ["-i", "/in", "-c:a", "libopus", "-f", "ogg", "/out"]],
37+
].filter(([name]) => !args.only || name.includes(args.only));
38+
39+
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
40+
41+
function native(setup, command) {
42+
const dir = mkdtempSync(join(tmpdir(), "ffbench-"));
43+
const local = (list) => list.map((a) => (a === "/in" || a === "/out" ? join(dir, a) : a));
44+
spawnSync("ffmpeg", ["-y", "-v", "error", ...local(setup)]);
45+
const times = [];
46+
for (let i = 0; i < runs; i++) {
47+
const start = performance.now();
48+
spawnSync("ffmpeg", ["-y", "-v", "error", ...local(command)]);
49+
times.push(performance.now() - start);
50+
}
51+
rmSync(dir, { recursive: true });
52+
return Math.round(median(times));
53+
}
54+
55+
const { server, url } = await startServer();
56+
const browser = await chromium.launch();
57+
const results = {};
58+
59+
for (const type of (args.type ?? "st,mt").split(",")) {
60+
const page = await browser.newPage();
61+
await page.goto(`${url}/tests/heavy/page.html?type=${type}`);
62+
await page.waitForFunction(() => window.ready);
63+
for (const [name, setup, command] of JOBS) {
64+
const times = [];
65+
for (let i = 0; i < runs; i++) {
66+
const r = await page.evaluate((job) => window.run(job), { setup, args: command });
67+
if (r.ret !== 0) throw new Error(`${name} (${type}) failed: ${r.tail}`);
68+
times.push(r.ms);
69+
}
70+
(results[name] ??= {})[type] = median(times);
71+
console.log(`${type} ${name}: ${results[name][type]} ms`);
72+
}
73+
await page.close();
74+
}
75+
await browser.close();
76+
server.close();
77+
78+
if (args.native) {
79+
for (const [name, setup, command] of JOBS) results[name].native = native(setup, command);
80+
}
81+
82+
const cols = [...(args.type ?? "st,mt").split(","), ...(args.native ? ["native"] : [])];
83+
console.log(`\n| job | ${cols.join(" ms | ")} ms |\n|---|${cols.map(() => "---:").join("|")}|`);
84+
for (const [name, r] of Object.entries(results)) console.log(`| ${name} | ${cols.map((c) => r[c]).join(" | ")} |`);
85+
86+
mkdirSync(join(root, "tests/heavy/results"), { recursive: true });
87+
writeFileSync(
88+
join(root, "tests/heavy/results/bench.json"),
89+
JSON.stringify({ date: new Date().toISOString(), cpu: cpus()[0].model, cores: cpus().length, runs, results }, null, 2)
90+
);

‎tests/heavy/fate.mjs‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Decodes every sample of FFmpeg's FATE suite with ffmpeg.wasm and compares
2+
// the result with tests/heavy/baseline/fate-<type>.json.
3+
//
4+
// pnpm test:heavy st and mt, fails on regressions
5+
// pnpm test:heavy --type=st --only=h264,vp9 --jobs=4
6+
// pnpm test:heavy --update write the results as the new baseline
7+
// pnpm test:heavy --native also run native `ffmpeg`, list what only it decodes
8+
//
9+
// The samples (~1.3 GB) are rsynced to .cache/fate-suite, or FATE_SUITE=<dir>
10+
// points at an existing copy.
11+
import { existsSync, mkdirSync, readFileSync, writeFileSync, symlinkSync, readdirSync, statSync } from "node:fs";
12+
import { execFileSync, spawnSync } from "node:child_process";
13+
import { join, relative, extname } from "node:path";
14+
import { chromium } from "playwright";
15+
import { startServer, root } from "../helpers/server.js";
16+
17+
const args = Object.fromEntries(
18+
process.argv.slice(2).map((a) => {
19+
const [key, value = true] = a.replace(/^--/, "").split("=");
20+
return [key, value];
21+
})
22+
);
23+
const types = (args.type ?? "st,mt").split(",");
24+
const jobs = Number(args.jobs ?? 4);
25+
const only = args.only?.split(",");
26+
const SECONDS = 5;
27+
const TIMEOUT_MS = 30000;
28+
29+
// Checksums, reference text and similar files in the suite that are not media.
30+
const NOT_MEDIA = new Set([".md5", ".sha1", ".sha256", ".txt", ".html", ".xml", ".json", ".lst", ".ffmeta", ".cfg", ".sub-ref"]);
31+
32+
const suite = join(root, ".cache/fate-suite");
33+
if (!existsSync(suite)) {
34+
mkdirSync(join(root, ".cache"), { recursive: true });
35+
if (process.env.FATE_SUITE) {
36+
symlinkSync(process.env.FATE_SUITE, suite);
37+
} else {
38+
console.log("Downloading the FATE suite to .cache/fate-suite (~1.3 GB)...");
39+
execFileSync("rsync", ["-rlLt", "--delete", "rsync://fate-suite.ffmpeg.org/fate-suite/", `${suite}/`], { stdio: "inherit" });
40+
}
41+
}
42+
43+
const walk = (dir) =>
44+
readdirSync(dir).sort().flatMap((name) => {
45+
const path = join(dir, name);
46+
return statSync(path).isDirectory() ? walk(path) : [path];
47+
});
48+
const files = walk(suite)
49+
.map((f) => relative(suite, f))
50+
.filter((f) => !NOT_MEDIA.has(extname(f).toLowerCase()))
51+
.filter((f) => !only || only.some((o) => f.startsWith(`${o}/`)));
52+
53+
// First video and first audio stream, at most SECONDS, hashed per frame.
54+
const command = (input) => ["-i", input, "-map", "0:v:0?", "-map", "0:a:0?", "-t", String(SECONDS), "-f", "framemd5", "/out"];
55+
56+
function summarize({ ret, error, out, tail, ms }) {
57+
if (error) return { status: "crash", error };
58+
if (ms >= TIMEOUT_MS) return { status: "timeout" };
59+
const frames = out?.text?.split("\n").filter((l) => l && !l.startsWith("#")).length ?? 0;
60+
if (ret !== 0 || !frames) return { status: "error", error: tail.split("\n").at(-1) };
61+
return { status: "ok", frames, hash: out.sha256.slice(0, 16) };
62+
}
63+
64+
function native(file) {
65+
const r = spawnSync("ffmpeg", ["-v", "error", ...command(join(suite, file)).slice(0, -1), "-"], { maxBuffer: 1 << 26, timeout: TIMEOUT_MS });
66+
const frames = r.stdout?.toString().split("\n").filter((l) => l && !l.startsWith("#")).length ?? 0;
67+
return r.status === 0 && frames > 0 ? "ok" : "error";
68+
}
69+
70+
const { server, url } = await startServer();
71+
const browser = await chromium.launch();
72+
let failed = false;
73+
74+
for (const type of types) {
75+
console.log(`\n# ${type}: ${files.length} files, ${jobs} pages`);
76+
const results = {};
77+
const queue = [...files];
78+
let done = 0;
79+
80+
const worker = async () => {
81+
let page;
82+
const open = async () => {
83+
await page?.close();
84+
page = await browser.newPage();
85+
await page.goto(`${url}/tests/heavy/page.html?type=${type}`);
86+
await page.waitForFunction(() => window.ready);
87+
};
88+
await open();
89+
while (queue.length) {
90+
const file = queue.shift();
91+
// Keep the extension: FFmpeg probes some formats by file name.
92+
const name = `/in${extname(file)}`;
93+
const job = page.evaluate((job) => window.run(job), {
94+
input: `/.cache/fate-suite/${file}`,
95+
name,
96+
args: command(name),
97+
timeout: TIMEOUT_MS,
98+
});
99+
// exec()'s own timeout can't interrupt a thread that is stuck, so also
100+
// give up from the outside and start a new page.
101+
const stuck = new Promise((r) => setTimeout(() => r({ error: "hang" }), TIMEOUT_MS + 15000));
102+
const r = await Promise.race([job, stuck]).catch((e) => ({ error: String(e) }));
103+
results[file] = r.error === "hang" ? { status: "hang" } : summarize(r);
104+
if (r.error === "hang") await open();
105+
if (++done % 100 === 0) console.log(` ${done}/${files.length}`);
106+
}
107+
await page.close();
108+
};
109+
await Promise.all(Array.from({ length: jobs }, worker));
110+
111+
if (args.native) {
112+
for (const file of files) results[file].native = native(file);
113+
}
114+
115+
const baselinePath = join(root, `tests/heavy/baseline/fate-${type}.json`);
116+
const baseline = existsSync(baselinePath) ? JSON.parse(readFileSync(baselinePath, "utf8")) : {};
117+
const regressions = [];
118+
const fixed = [];
119+
const changed = [];
120+
for (const file of files) {
121+
const now = results[file];
122+
const was = baseline[file];
123+
if (!was) continue;
124+
if (was.status === "ok" && now.status !== "ok") regressions.push(`${file}: ${now.status} ${now.error ?? ""}`);
125+
else if (was.status !== "ok" && now.status === "ok") fixed.push(file);
126+
else if (was.status === "ok" && was.hash !== now.hash) changed.push(`${file}: ${was.frames} -> ${now.frames} frames`);
127+
}
128+
129+
const count = (s) => files.filter((f) => results[f].status === s).length;
130+
console.log(` ok ${count("ok")} error ${count("error")} crash ${count("crash")} timeout ${count("timeout")} hang ${count("hang")}`);
131+
if (args.native) {
132+
const gaps = files.filter((f) => results[f].native === "ok" && results[f].status !== "ok");
133+
console.log(` native ffmpeg decodes ${files.filter((f) => results[f].native === "ok").length}; ffmpeg.wasm fails ${gaps.length} of those:`);
134+
for (const f of gaps) console.log(` ${f}: ${results[f].status} ${results[f].error ?? ""}`);
135+
}
136+
for (const [title, list] of [["regressions", regressions], ["now decoding", fixed], ["different output", changed]]) {
137+
if (list.length) console.log(` ${title} (${list.length}):\n ${list.join("\n ")}`);
138+
}
139+
140+
mkdirSync(join(root, "tests/heavy/results"), { recursive: true });
141+
writeFileSync(join(root, `tests/heavy/results/fate-${type}.json`), JSON.stringify(results, null, 1));
142+
if (args.update) {
143+
mkdirSync(join(root, "tests/heavy/baseline"), { recursive: true });
144+
// One file per line, so baseline changes read well in a diff.
145+
const lines = files.map((f) => {
146+
const { status, frames, hash } = results[f];
147+
return `${JSON.stringify(f)}: ${JSON.stringify({ status, frames, hash })}`;
148+
});
149+
writeFileSync(baselinePath, `{\n${lines.join(",\n")}\n}\n`);
150+
console.log(` wrote ${relative(root, baselinePath)}`);
151+
} else if (regressions.length) {
152+
failed = true;
153+
}
154+
}
155+
156+
await browser.close();
157+
server.close();
158+
process.exit(failed ? 1 : 0);

‎tests/heavy/page.html‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>ffmpeg.wasm heavy tests</title>
6+
</head>
7+
<body>
8+
<script type="module">
9+
import { FFmpeg } from "/packages/ffmpeg/dist/esm/index.js";
10+
11+
const type = new URLSearchParams(location.search).get("type");
12+
const coreURL = `${location.origin}/packages/core${type === "mt" ? "-mt" : ""}/dist/esm/ffmpeg-core.js`;
13+
let ffmpeg;
14+
const logs = [];
15+
16+
async function load() {
17+
ffmpeg?.terminate();
18+
ffmpeg = new FFmpeg();
19+
ffmpeg.on("log", ({ message }) => logs.push(message));
20+
await ffmpeg.load({ coreURL });
21+
}
22+
23+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
24+
25+
// Runs `args` and returns the exit code, time, the last log lines and
26+
// the SHA-256 of /out. `name` (default /in) is the fetched `input`, or
27+
// what the untimed `setup` command wrote.
28+
window.run = async ({ input, name = "/in", setup, args, timeout }) => {
29+
if (!ffmpeg) await load();
30+
if (input) await ffmpeg.writeFile(name, new Uint8Array(await (await fetch(input)).arrayBuffer()));
31+
if (setup) await ffmpeg.exec(setup);
32+
logs.length = 0;
33+
const start = performance.now();
34+
let ret;
35+
let error;
36+
try {
37+
ret = await ffmpeg.exec(args, timeout);
38+
} catch (e) {
39+
error = String(e);
40+
}
41+
const ms = Math.round(performance.now() - start);
42+
let out;
43+
if (ret !== undefined) {
44+
try {
45+
out = await ffmpeg.readFile("/out");
46+
await ffmpeg.deleteFile("/out");
47+
} catch {
48+
// no output
49+
}
50+
if (input || setup) await ffmpeg.deleteFile(name);
51+
} else {
52+
await load(); // the core threw or aborted; start over with a fresh one
53+
}
54+
return {
55+
ret, error, ms,
56+
tail: logs.filter((l) => l !== "Aborted()").slice(-3).join("\n"),
57+
out: out && { bytes: out.length, sha256: hex(await crypto.subtle.digest("SHA-256", out)), text: out.length < 1e6 ? new TextDecoder().decode(out) : null },
58+
};
59+
};
60+
window.reload = load;
61+
window.ready = true;
62+
</script>
63+
</body>
64+
</html>

0 commit comments

Comments
 (0)