Skip to content

Commit 9eba2e0

Browse files
committed
Heavy tests: FFmpeg's FATE samples and benchmarks, with Vitest
`pnpm test:heavy` decodes every sample of FFmpeg's FATE suite (~2.5k files, rsynced to .cache/fate-suite, or FATE_SUITE=<dir>) with each core in Node.js: first video and audio stream, at most 5 s, to framemd5. Each result must match the snapshot in tests/heavy/baseline/fate-{st,mt}.snap; `-u` updates it and `-t h264/` narrows the run. FATE_NATIVE=1 also decodes each sample with native ffmpeg and fails the ones only one of them decodes. `pnpm bench` times common jobs on FATE samples (h264/vp9 decode, scale, remux, x264, vp9, aac/mp3/opus) with each core and prints an ST vs MT table, plus native ffmpeg with FATE_NATIVE=1. 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 5b7c68d commit 9eba2e0

9 files changed

Lines changed: 207 additions & 1 deletion

File tree

‎.github/workflows/CI.yml‎

Lines changed: 44 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,43 @@ 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+
- name: FATE samples
141+
run: pnpm test:heavy --reporter=default --reporter=json --outputFile.json=tests/heavy/results/fate.json
142+
- name: FATE baseline from this run, to commit if the changes are expected
143+
if: failure()
144+
run: pnpm test:heavy -u
145+
- name: Benchmarks
146+
if: always()
147+
run: pnpm bench --reporter=default --reporter=json --outputFile.json=tests/heavy/results/bench.json
148+
- uses: actions/upload-artifact@v4
149+
if: always()
150+
with:
151+
name: heavy-results
152+
path: |
153+
tests/heavy/results
154+
tests/heavy/baseline

‎.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": "vitest run --config tests/heavy/vitest.config.js",
19+
"bench": "vitest bench --run --config tests/heavy/vitest.config.js"
1820
},
1921
"devDependencies": {
2022
"@ffmpeg/ffmpeg": "workspace:*",

‎tests/heavy/fate.js‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { spawnSync } from "node:child_process";
2+
import { createHash } from "node:crypto";
3+
import { readdirSync, readFileSync, statSync } from "node:fs";
4+
import { extname, join } from "node:path";
5+
import { inject } from "vitest";
6+
import { createCore } from "../helpers/core.js";
7+
import { exec } from "../helpers/run.js";
8+
9+
export const suite = inject("suite");
10+
11+
const NOT_MEDIA = [".md5", ".sha1", ".sha256", ".txt", ".html", ".xml", ".json", ".lst", ".ffmeta", ".cfg", ".sub-ref"];
12+
13+
export const samples = readdirSync(suite, { recursive: true })
14+
.filter((sample) => statSync(join(suite, sample)).isFile())
15+
.filter((sample) => !NOT_MEDIA.includes(extname(sample).toLowerCase()))
16+
.sort();
17+
18+
// Damaged streams whose error concealment differs between runs with frame threads.
19+
const UNSTABLE_OUTPUT = ["mov/empty_edit_5s.mp4"];
20+
21+
// The first video and audio stream, at most 5 s, hashed per frame.
22+
const framemd5 = (input, output) => ["-i", input, "-map", "0:v:0?", "-map", "0:a:0?", "-t", "5", "-f", "framemd5", output];
23+
24+
function summarize(sample, out) {
25+
const frames = out.split("\n").filter((line) => line && !line.startsWith("#")).length;
26+
const hash = createHash("sha256").update(out).digest("hex").slice(0, 16);
27+
if (!frames) return { status: "error" };
28+
return UNSTABLE_OUTPUT.includes(sample) ? { status: "ok", frames } : { status: "ok", frames, hash };
29+
}
30+
31+
function decode(core, sample) {
32+
// keep the extension: FFmpeg guesses some formats from the file name
33+
const input = `/in${extname(sample)}`;
34+
core.FS.writeFile(input, readFileSync(join(suite, sample)));
35+
core.setTimeout(60000);
36+
const { ret } = exec(core, ...framemd5(input, "/out"));
37+
const out = ret === 0 ? core.FS.readFile("/out", { encoding: "utf8" }) : "";
38+
[input, "/out"].filter((path) => core.FS.analyzePath(path).exists).forEach((path) => core.FS.unlink(path));
39+
return summarize(sample, out);
40+
}
41+
42+
// Decodes samples with one core; a crash leaves it unusable, so the next
43+
// sample gets a new one.
44+
export function decoder() {
45+
const cores = {};
46+
return async (sample) => {
47+
cores.current ??= await createCore();
48+
try {
49+
return decode(cores.current, sample);
50+
} catch {
51+
delete cores.current;
52+
return { status: "crash" };
53+
}
54+
};
55+
}
56+
57+
export function decodeNatively(sample) {
58+
const { status, stdout } = spawnSync("ffmpeg", ["-v", "error", ...framemd5(join(suite, sample), "-")], { maxBuffer: 1 << 26, timeout: 60000 });
59+
return status === 0 ? summarize(sample, stdout.toString()) : { status: "error" };
60+
}

‎tests/heavy/fate.test.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Decodes every sample of FFmpeg's FATE suite and compares the result with
2+
// tests/heavy/baseline. `pnpm test:heavy -u` updates the baseline.
3+
import { expect, test } from "vitest";
4+
import { decoder, samples } from "./fate.js";
5+
6+
const decode = decoder();
7+
8+
test.for(samples)("%s", async (sample) => {
9+
const result = await decode(sample);
10+
expect(result).toMatchSnapshot();
11+
});

‎tests/heavy/ffmpeg.bench.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Times common jobs on FATE samples with each core; with FATE_NATIVE=1, also
2+
// with native `ffmpeg`.
3+
import { execFileSync } from "node:child_process";
4+
import { readFileSync } from "node:fs";
5+
import { basename, join } from "node:path";
6+
import { test } from "vitest";
7+
import { createCore } from "../helpers/core.js";
8+
import { exec } from "../helpers/run.js";
9+
import { suite } from "./fate.js";
10+
11+
const H264_1080P = "h264/h264refframeregression.mp4";
12+
const VP9_480P = "vp9-test-vectors/vp90-2-15-segkey_adpq.webm";
13+
const WAV = "audio-reference/luckynight_2ch_44kHz_s16.wav";
14+
15+
const JOBS = [
16+
["decode h264 1080p", H264_1080P, ["-f", "null", "-"]],
17+
["decode vp9 480p", VP9_480P, ["-f", "null", "-"]],
18+
["scale 1080p to 480p", H264_1080P, ["-vf", "scale=854:480", "-f", "null", "-"]],
19+
["remux mp4 to mkv", H264_1080P, ["-c", "copy", "-f", "matroska", "/out"]],
20+
["x264 veryfast 720p", H264_1080P, ["-t", "2", "-vf", "scale=1280:720", "-c:v", "libx264", "-preset", "veryfast", "-an", "-f", "mp4", "/out"]],
21+
["vp9 realtime 480p", VP9_480P, ["-c:v", "libvpx-vp9", "-deadline", "realtime", "-cpu-used", "8", "-f", "webm", "/out"]],
22+
["aac encode", WAV, ["-c:a", "aac", "-f", "ipod", "/out"]],
23+
["mp3 encode", WAV, ["-f", "mp3", "/out"]],
24+
["opus encode", WAV, ["-c:a", "libopus", "-f", "ogg", "/out"]],
25+
];
26+
27+
const RUNS = { iterations: 3, time: 0, warmupIterations: 1, warmupTime: 0 };
28+
29+
const core = await createCore();
30+
[H264_1080P, VP9_480P, WAV].forEach((sample) => core.FS.writeFile(`/${basename(sample)}`, readFileSync(join(suite, sample))));
31+
32+
test.for(JOBS)("%s", async ([, sample, args], { bench }) => {
33+
const wasm = bench("ffmpeg.wasm", { perProject: true }, () => exec(core, "-i", `/${basename(sample)}`, ...args));
34+
const nativeArgs = ["-v", "error", "-y", "-i", join(suite, sample), ...args.map((arg) => (arg === "/out" ? "/dev/null" : arg))];
35+
const native = bench("native ffmpeg", () => execFileSync("ffmpeg", nativeArgs, { stdio: "ignore" }));
36+
await (process.env.FATE_NATIVE ? bench.compare(wasm, native, RUNS) : wasm.run(RUNS));
37+
});

‎tests/heavy/native.test.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// With FATE_NATIVE=1, the samples native `ffmpeg` decodes and ffmpeg.wasm
2+
// doesn't (or the other way around) fail.
3+
import { expect, test } from "vitest";
4+
import { decodeNatively, decoder, samples } from "./fate.js";
5+
6+
const decode = decoder();
7+
8+
test.skipIf(!process.env.FATE_NATIVE).for(samples)("%s", async (sample) => {
9+
const native = decodeNatively(sample);
10+
const wasm = await decode(sample);
11+
expect(wasm.status).to.equal(native.status);
12+
});

‎tests/heavy/setup.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { execFileSync } from "node:child_process";
2+
import { existsSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
5+
const cache = fileURLToPath(new URL("../../.cache/fate-suite", import.meta.url));
6+
7+
// FFmpeg's FATE samples (~1.3 GB): FATE_SUITE=<dir>, or downloaded to .cache/fate-suite.
8+
export default function setup({ provide }) {
9+
const suite = process.env.FATE_SUITE ?? cache;
10+
if (!existsSync(suite)) {
11+
execFileSync("rsync", ["-rlLt", "rsync://fate-suite.ffmpeg.org/fate-suite/", `${suite}/`], { stdio: "inherit" });
12+
}
13+
provide("suite", suite);
14+
}

‎tests/heavy/vitest.config.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { basename } from "node:path";
2+
import { defineConfig } from "vitest/config";
3+
4+
// FFmpeg's FATE samples and benchmarks, with @ffmpeg/core in Node.js like the
5+
// core tests. Too slow for `pnpm test`: run with `pnpm test:heavy` and `pnpm bench`.
6+
const heavy = (name) => ({
7+
test: {
8+
name: `heavy-${name}`,
9+
root: import.meta.dirname,
10+
include: ["*.test.js"],
11+
globalSetup: ["setup.js"],
12+
provide: { core: name },
13+
},
14+
});
15+
16+
export default defineConfig({
17+
test: {
18+
testTimeout: 120000,
19+
projects: [heavy("st"), heavy("mt")],
20+
// one baseline per core: baseline/fate-st.snap, baseline/fate-mt.snap
21+
resolveSnapshotPath: (path, extension, { config }) =>
22+
`${import.meta.dirname}/baseline/${basename(path, ".test.js")}-${config.name.replace("heavy-", "")}${extension}`,
23+
},
24+
});

0 commit comments

Comments
 (0)