|
| 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); |
0 commit comments