mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 20:28:32 +09:00
932 lines
29 KiB
JavaScript
932 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createWriteStream, readFileSync } from "node:fs";
|
|
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const BACKENDS = ["DirectGLES", "DirectVulkan"];
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_FIXTURE_ROOT = path.join(SCRIPT_DIR, "fixtures");
|
|
const TRACE_CASES = JSON.parse(readFileSync(path.join(SCRIPT_DIR, "trace_cases.json"), "utf8"));
|
|
const CASES = TRACE_CASES.cases.map((traceCase) => traceCase.name);
|
|
|
|
function usage() {
|
|
console.error(`Usage:
|
|
node tools/trace_replay/render_retrace_summary.mjs \\
|
|
--input DIR [--input DIR ...] \\
|
|
--output-dir DIR \\
|
|
[--title TITLE] [--group-label LABEL] [--html NAME]`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
inputs: [],
|
|
outputDir: "",
|
|
title: "MobileGL retrace overview",
|
|
groupLabel: "retrace",
|
|
htmlName: "mobilegl-retrace-overview.html",
|
|
};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
const value = argv[i + 1];
|
|
if (arg === "--input" && value) {
|
|
args.inputs.push(path.resolve(value));
|
|
i += 1;
|
|
} else if (arg === "--output-dir" && value) {
|
|
args.outputDir = path.resolve(value);
|
|
i += 1;
|
|
} else if (arg === "--title" && value) {
|
|
args.title = value;
|
|
i += 1;
|
|
} else if (arg === "--group-label" && value) {
|
|
args.groupLabel = value;
|
|
i += 1;
|
|
} else if (arg === "--html" && value) {
|
|
args.htmlName = value;
|
|
i += 1;
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
usage();
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown or incomplete argument: ${arg}`);
|
|
}
|
|
}
|
|
if (args.inputs.length === 0 || !args.outputDir) {
|
|
usage();
|
|
process.exit(2);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
async function exists(filePath) {
|
|
try {
|
|
await stat(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isUsablePng(filePath) {
|
|
try {
|
|
const info = await stat(filePath);
|
|
return info.isFile() && filePath.toLowerCase().endsWith(".png") && info.size > 1024;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function walk(root) {
|
|
const results = [];
|
|
if (!(await exists(root))) return results;
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...await walk(fullPath));
|
|
} else if (entry.isFile()) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function safeCase(caseName) {
|
|
return caseName.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
}
|
|
|
|
function shortCase(caseName) {
|
|
return caseName
|
|
.replace(/^minecraft-1\.21\.4-/, "mc-")
|
|
.replaceAll("fabric-iris-", "")
|
|
.replaceAll("fabric-", "")
|
|
.replace(/-in-world$/, "");
|
|
}
|
|
|
|
function normalizeSlashes(value) {
|
|
return value.split(path.sep).join("/");
|
|
}
|
|
|
|
function inferCaseBackend(filePath) {
|
|
const text = normalizeSlashes(filePath);
|
|
const backend = BACKENDS.find((candidate) => text.includes(candidate)) ?? "";
|
|
const sortedCases = [...CASES].sort((a, b) => b.length - a.length);
|
|
const caseName = sortedCases.find((candidate) =>
|
|
text.includes(candidate) || text.includes(safeCase(candidate))
|
|
) ?? "";
|
|
return { caseName, backend };
|
|
}
|
|
|
|
function inferGroup(filePath, defaultGroup) {
|
|
const text = normalizeSlashes(filePath);
|
|
for (const segment of text.split("/")) {
|
|
const match = segment.match(/^android-retrace-(?:result|fixture|work)-(.+)$/);
|
|
if (!match) continue;
|
|
const parts = match[1].split("-");
|
|
const serial = parts[parts.length - 1];
|
|
if (serial) return serial;
|
|
}
|
|
return defaultGroup;
|
|
}
|
|
|
|
function parseGpuLabel(logText) {
|
|
const renderer = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s*GL_RENDERER:\s*(.+?)\s*$/m)
|
|
?? logText.match(/^GL_RENDERER:\s*(.+?)\s*$/m);
|
|
if (renderer?.[1]) return renderer[1].trim();
|
|
|
|
const vulkanDevice = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m)
|
|
?? logText.match(/^\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m);
|
|
if (vulkanDevice?.[1]) return vulkanDevice[1].trim();
|
|
|
|
return "";
|
|
}
|
|
|
|
async function collectGroupLabels(files, defaultGroup) {
|
|
const labels = new Map();
|
|
const logFiles = files.filter((file) => {
|
|
const name = path.basename(file).toLowerCase();
|
|
return name === "mobilegl.log" || name === "logcat.txt";
|
|
});
|
|
for (const logFile of logFiles) {
|
|
const group = inferGroup(logFile, defaultGroup);
|
|
try {
|
|
const gpuLabel = parseGpuLabel(await readFile(logFile, "utf8"));
|
|
if (gpuLabel) labels.set(group, gpuLabel);
|
|
} catch {
|
|
// Diagnostics should not make summary rendering fail.
|
|
}
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
|
|
function scoreImage(filePath, caseName, backend, kind) {
|
|
const text = normalizeSlashes(filePath);
|
|
const name = path.basename(filePath).toLowerCase();
|
|
const fixtureGoldenPrefix = `${caseName}.`.toLowerCase();
|
|
const lowerKind = kind.toLowerCase();
|
|
if (lowerKind === "golden" && name.includes("alternate-golden")) return 0;
|
|
const matchesKind =
|
|
name === `${lowerKind}.png` ||
|
|
name.endsWith(`-${lowerKind}.png`) ||
|
|
name.endsWith(`_${lowerKind}.png`);
|
|
const matchesFixtureGolden =
|
|
(lowerKind === "golden" || lowerKind === "alternate-golden") &&
|
|
name.startsWith(fixtureGoldenPrefix) &&
|
|
name.endsWith(".png");
|
|
if (!matchesKind && !matchesFixtureGolden) return 0;
|
|
|
|
let score = 0;
|
|
if (text.includes(caseName) || text.includes(safeCase(caseName))) score += 4;
|
|
if (text.includes(backend)) score += 3;
|
|
score += 3;
|
|
if (matchesFixtureGolden) {
|
|
score += 4;
|
|
if (lowerKind === "alternate-golden" && name.includes("-mali.")) score += 3;
|
|
if (lowerKind === "golden" && !name.includes("-mali.")) score += 2;
|
|
}
|
|
if (name === `${caseName}-${backend}-${kind}.png`.toLowerCase()) score += 4;
|
|
if (name === `${safeCase(caseName)}-${backend}-${kind}.png`.toLowerCase()) score += 4;
|
|
if (name === `${kind}.png`) score += 4;
|
|
return score;
|
|
}
|
|
|
|
async function findImage(pngs, caseName, backend, kind, nearFile) {
|
|
const exactNames = [
|
|
`${caseName}-${backend}-${kind}.png`,
|
|
`${safeCase(caseName)}-${backend}-${kind}.png`,
|
|
`${caseName}-${kind}.png`,
|
|
`${safeCase(caseName)}-${kind}.png`,
|
|
`${kind}.png`,
|
|
];
|
|
|
|
if (nearFile) {
|
|
let current = path.dirname(nearFile);
|
|
for (let depth = 0; depth < 5; depth += 1) {
|
|
for (const name of exactNames) {
|
|
const candidate = path.join(current, name);
|
|
if (await isUsablePng(candidate)) return candidate;
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
const scored = [];
|
|
const nearGroup = nearFile ? inferGroup(nearFile, "") : "";
|
|
for (const png of pngs) {
|
|
const text = normalizeSlashes(png);
|
|
if ((kind === "actual" || kind === "diff") && backend && !text.includes(backend)) continue;
|
|
const pngGroup = inferGroup(png, "");
|
|
if (nearGroup && pngGroup && pngGroup !== nearGroup) continue;
|
|
const score = scoreImage(png, caseName, backend, kind);
|
|
if (score >= 7) scored.push({ score, png });
|
|
}
|
|
scored.sort((a, b) => b.score - a.score || a.png.length - b.png.length);
|
|
return scored[0]?.png ?? "";
|
|
}
|
|
|
|
async function collectRows(inputs, defaultGroup) {
|
|
const files = [];
|
|
for (const input of inputs) files.push(...await walk(input));
|
|
const groupLabels = await collectGroupLabels(files, defaultGroup);
|
|
const fixtureFiles = await walk(DEFAULT_FIXTURE_ROOT);
|
|
const pngs = [];
|
|
for (const file of [...files, ...fixtureFiles]) {
|
|
if (await isUsablePng(file)) pngs.push(file);
|
|
}
|
|
|
|
const rows = new Map();
|
|
|
|
for (const resultJson of files.filter((file) => path.basename(file) === "result.json" || file.endsWith("-result.json"))) {
|
|
let { caseName, backend } = inferCaseBackend(resultJson);
|
|
let result = {};
|
|
try {
|
|
result = JSON.parse(await readFile(resultJson, "utf8"));
|
|
backend = backend || result.backend || "";
|
|
} catch (error) {
|
|
result = { passed: false, message: `failed to parse result.json: ${error.message}` };
|
|
}
|
|
if (!caseName || !BACKENDS.includes(backend)) continue;
|
|
const group = inferGroup(resultJson, defaultGroup);
|
|
const key = `${group}\0${backend}\0${caseName}`;
|
|
const matchedGolden = result.matchedGoldenPath || "";
|
|
const alternateGoldens = Array.isArray(result.alternateGoldenPaths) ? result.alternateGoldenPaths : [];
|
|
const useAlternateGolden = alternateGoldens.some((alternate) =>
|
|
path.basename(String(alternate)) === path.basename(String(matchedGolden))
|
|
) || String(matchedGolden).includes("alternate-golden");
|
|
rows.set(key, {
|
|
group,
|
|
backend,
|
|
caseName,
|
|
status: result.passed ? "PASS" : "FAIL",
|
|
ssim: result.ssim === undefined || result.ssim === null ? "" : String(result.ssim),
|
|
message: result.message || "",
|
|
actual: await findImage(pngs, caseName, backend, "actual", resultJson),
|
|
golden: await findImage(pngs, caseName, backend, useAlternateGolden ? "alternate-golden" : "golden", resultJson),
|
|
diff: await findImage(pngs, caseName, backend, "diff", resultJson),
|
|
});
|
|
}
|
|
|
|
// Preserve visibility for jobs that uploaded logs/diagnostics but never wrote result.json.
|
|
const directories = new Set(files.map((file) => path.dirname(file)));
|
|
for (const directory of directories) {
|
|
const { caseName, backend } = inferCaseBackend(directory);
|
|
if (!caseName || !BACKENDS.includes(backend)) continue;
|
|
const group = inferGroup(directory, defaultGroup);
|
|
const key = `${group}\0${backend}\0${caseName}`;
|
|
if (rows.has(key)) continue;
|
|
rows.set(key, {
|
|
group,
|
|
backend,
|
|
caseName,
|
|
status: "NO_RESULT",
|
|
ssim: "",
|
|
message: "result.json was not found",
|
|
actual: await findImage(pngs, caseName, backend, "actual", directory),
|
|
golden: await findImage(pngs, caseName, backend, "golden", directory),
|
|
diff: await findImage(pngs, caseName, backend, "diff", directory),
|
|
});
|
|
}
|
|
|
|
return { rows: [...rows.values()], groupLabels };
|
|
}
|
|
|
|
function htmlEscape(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function jsonScriptText(value) {
|
|
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
}
|
|
|
|
async function embedPng(source) {
|
|
if (!source) return "";
|
|
if (!(await isUsablePng(source))) return "";
|
|
const data = await readFile(source);
|
|
return `data:image/png;base64,${data.toString("base64")}`;
|
|
}
|
|
|
|
function statusText(row) {
|
|
if (!row) return "NO RESULT";
|
|
const effective = effectiveStatus(row);
|
|
if (effective === "FAIL" && row.status === "NO_RESULT") return `FAIL <span>NO RESULT</span>`;
|
|
if (row.status === "NO_RESULT") return "NO RESULT";
|
|
if (row.status === "FIXTURE_MISSING") return "MISSING FIXTURE";
|
|
if (row.ssim) {
|
|
const value = Number(row.ssim);
|
|
const text = Number.isFinite(value) ? value.toFixed(4) : row.ssim;
|
|
return `${row.status} <span>SSIM ${htmlEscape(text)}</span>`;
|
|
}
|
|
return row.status;
|
|
}
|
|
|
|
function effectiveStatus(row) {
|
|
if (!row) return "NO_RESULT";
|
|
if (row.status === "PASS" || row.status === "FAIL") return row.status;
|
|
return !row.actualSrc && !row.goldenSrc ? "NO_RESULT" : "FAIL";
|
|
}
|
|
|
|
async function renderHtml(rows, groupLabels, outputDir, title, groupLabel, htmlName) {
|
|
await mkdir(outputDir, { recursive: true });
|
|
const groups = [...new Set(rows.map((row) => row.group))].sort();
|
|
if (groups.length === 0) groups.push(groupLabel);
|
|
const rowMap = new Map(rows.map((row) => [`${row.group}\0${row.backend}\0${row.caseName}`, row]));
|
|
|
|
const cells = new Map();
|
|
for (const row of rows) {
|
|
cells.set(`${row.group}\0${row.backend}\0${row.caseName}`, {
|
|
...row,
|
|
actualSrc: await embedPng(row.actual),
|
|
goldenSrc: await embedPng(row.golden),
|
|
diffSrc: await embedPng(row.diff),
|
|
});
|
|
}
|
|
|
|
const imageTemplates = [];
|
|
const imageTemplatesByGroup = new Map();
|
|
const imageTemplateForGroup = (group) => {
|
|
let template = imageTemplatesByGroup.get(group);
|
|
if (!template) {
|
|
template = { id: `image-data-${imageTemplates.length}`, images: {}, nextImage: 0 };
|
|
imageTemplatesByGroup.set(group, template);
|
|
imageTemplates.push(template);
|
|
}
|
|
return template;
|
|
};
|
|
const registerImage = (group, src) => {
|
|
if (!src) return "";
|
|
const template = imageTemplateForGroup(group);
|
|
const id = `image-${template.nextImage}`;
|
|
template.nextImage += 1;
|
|
template.images[id] = src;
|
|
return id;
|
|
};
|
|
|
|
const thumb = (group, src, label) => {
|
|
const imageId = registerImage(group, src);
|
|
return `
|
|
<figure class="thumb">
|
|
<figcaption>${htmlEscape(label)}</figcaption>
|
|
${imageId ? `<button class="image-button" type="button" aria-label="Open ${htmlEscape(label)} image"><img class="lazy-image" data-image-id="${imageId}" alt="${htmlEscape(label)}" loading="lazy" decoding="async"></button>` : `<div class="no-image">NO IMAGE</div>`}
|
|
</figure>`;
|
|
};
|
|
|
|
const missingImages = (row) => !row.actualSrc || !row.goldenSrc || !row.diffSrc;
|
|
const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? {
|
|
status: "NO_RESULT",
|
|
actualSrc: "",
|
|
goldenSrc: "",
|
|
diffSrc: "",
|
|
};
|
|
|
|
const backendCell = (group, backend, caseName) => {
|
|
const row = cellFor(group, backend, caseName);
|
|
const effective = effectiveStatus(row);
|
|
const statusClass = effective === "PASS" ? "pass" : effective === "NO_RESULT" ? "missing" : "fail";
|
|
const imageClass = missingImages(row) ? "image-missing" : "";
|
|
return `
|
|
<section class="backend-cell ${statusClass} ${imageClass}">
|
|
<header>${backend} <strong>${statusText(row)}</strong></header>
|
|
<div class="thumbs">
|
|
${thumb(group, row.actualSrc, "actual")}
|
|
${thumb(group, row.goldenSrc, "golden")}
|
|
${thumb(group, row.diffSrc, "diff")}
|
|
</div>
|
|
</section>`;
|
|
};
|
|
|
|
const output = path.join(outputDir, htmlName);
|
|
const stream = createWriteStream(output, { encoding: "utf8" });
|
|
const write = (chunk) => new Promise((resolve, reject) => {
|
|
stream.write(chunk, (error) => error ? reject(error) : resolve());
|
|
});
|
|
const finish = () => new Promise((resolve, reject) => {
|
|
stream.end((error) => error ? reject(error) : resolve());
|
|
});
|
|
|
|
await write(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${htmlEscape(title)}</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #141414;
|
|
--row-a: #1e1e1e;
|
|
--row-b: #242424;
|
|
--panel: #181818;
|
|
--image-bg: #242424;
|
|
--line: #3a3a3a;
|
|
--text: #e8e8e8;
|
|
--muted: #aaa;
|
|
--green: #1a9453;
|
|
--red: #d22d2d;
|
|
--orange: #cd841c;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: Arial, Helvetica, sans-serif;
|
|
font-size: 12px;
|
|
}
|
|
main { width: min(1170px, 100%); margin: 0 auto; padding: 14px 10px 96px; }
|
|
.document-title {
|
|
margin: 0 0 6px;
|
|
font-size: 28px;
|
|
line-height: 1.2;
|
|
}
|
|
.overview-note {
|
|
margin: 0 0 16px;
|
|
color: var(--muted);
|
|
}
|
|
.group { margin-bottom: 28px; }
|
|
.group-title {
|
|
display: flex;
|
|
align-items: start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 10px;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #2f2f2f;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.group-title:hover { border-color: #5a6473; }
|
|
.group-title:focus-visible {
|
|
outline: 2px solid #6f87ff;
|
|
outline-offset: 2px;
|
|
}
|
|
.group-title::after {
|
|
content: "+ Expand";
|
|
flex: 0 0 auto;
|
|
align-self: center;
|
|
color: var(--muted);
|
|
font-weight: 700;
|
|
}
|
|
.group:not(.collapsed) .group-title::after {
|
|
content: "- Collapse";
|
|
}
|
|
h1 { margin: 0; font-size: 24px; line-height: 1.2; }
|
|
p { margin: 4px 0 12px; color: var(--muted); }
|
|
.stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin: 8px 0 12px;
|
|
}
|
|
.stat {
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 5px 8px;
|
|
color: var(--text);
|
|
background: #151922;
|
|
font-weight: 700;
|
|
}
|
|
.stat strong {
|
|
margin-left: 5px;
|
|
font-size: 14px;
|
|
}
|
|
.stat.rate {
|
|
border-color: #6f87ff;
|
|
background: #1d2544;
|
|
}
|
|
.stat-break {
|
|
flex-basis: 100%;
|
|
height: 0;
|
|
}
|
|
.stat.pass {
|
|
border-color: var(--green);
|
|
background: #15341e;
|
|
}
|
|
.stat.fail {
|
|
border-color: var(--red);
|
|
background: #3a171a;
|
|
}
|
|
.stat.no-result {
|
|
border-color: var(--orange);
|
|
background: #3b2a10;
|
|
}
|
|
.status-bar {
|
|
display: flex;
|
|
width: min(560px, 100%);
|
|
height: 14px;
|
|
margin: -4px 0 12px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
background: #151922;
|
|
}
|
|
.status-bar .segment {
|
|
min-width: 0;
|
|
height: 100%;
|
|
}
|
|
.status-bar .pass { background: var(--green); }
|
|
.status-bar .no-result { background: var(--orange); }
|
|
.status-bar .fail { background: var(--red); }
|
|
.status-bar .empty {
|
|
width: 100%;
|
|
background: #252b38;
|
|
}
|
|
.group.collapsed .case-list { display: none; }
|
|
.case-list { overflow-x: auto; }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 280px 430px 430px;
|
|
column-gap: 10px;
|
|
min-width: 1150px;
|
|
}
|
|
.header-row {
|
|
color: var(--muted);
|
|
font-weight: 700;
|
|
margin-bottom: 4px;
|
|
}
|
|
.case-row {
|
|
min-height: 112px;
|
|
padding: 5px 0;
|
|
align-items: stretch;
|
|
}
|
|
.case-row.even { background: var(--row-a); }
|
|
.case-row.odd { background: var(--row-b); }
|
|
aside {
|
|
padding: 22px 8px 0 2px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
aside small {
|
|
display: block;
|
|
margin-top: 5px;
|
|
color: var(--orange);
|
|
}
|
|
.backend-cell {
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
min-height: 102px;
|
|
position: relative;
|
|
}
|
|
.backend-cell.fail { border: 3px solid var(--red); }
|
|
.backend-cell.missing { border: 2px solid var(--orange); }
|
|
.backend-cell.image-missing::after {
|
|
content: "";
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
width: 0;
|
|
height: 0;
|
|
border-top: 16px solid var(--orange);
|
|
border-left: 16px solid transparent;
|
|
}
|
|
.backend-cell > header {
|
|
height: 22px;
|
|
padding: 4px 7px;
|
|
font-weight: 700;
|
|
background: var(--green);
|
|
}
|
|
.backend-cell.fail > header { background: var(--red); }
|
|
.backend-cell.missing > header { background: var(--orange); }
|
|
.backend-cell header span { margin-left: 4px; }
|
|
.thumbs {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 5px;
|
|
padding: 5px;
|
|
}
|
|
.thumb {
|
|
height: 74px;
|
|
margin: 0;
|
|
border: 1px solid var(--line);
|
|
background: var(--image-bg);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.thumb figcaption {
|
|
position: absolute;
|
|
left: 4px;
|
|
top: 2px;
|
|
z-index: 1;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
}
|
|
.image-button {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
background: transparent;
|
|
cursor: zoom-in;
|
|
}
|
|
.thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
padding-top: 12px;
|
|
display: block;
|
|
}
|
|
.no-image {
|
|
height: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
padding-top: 12px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
.lightbox {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: max(12px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left));
|
|
background: rgb(0 0 0 / 86%);
|
|
cursor: zoom-out;
|
|
}
|
|
.lightbox[hidden] {
|
|
display: none;
|
|
}
|
|
.lightbox-close {
|
|
position: fixed;
|
|
right: max(10px, env(safe-area-inset-right));
|
|
top: max(10px, env(safe-area-inset-top));
|
|
z-index: 21;
|
|
width: 38px;
|
|
height: 38px;
|
|
border: 1px solid rgb(255 255 255 / 24%);
|
|
border-radius: 999px;
|
|
color: var(--text);
|
|
background: rgb(18 18 18 / 92%);
|
|
font-size: 24px;
|
|
line-height: 34px;
|
|
cursor: pointer;
|
|
}
|
|
.lightbox-stage {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: auto;
|
|
overscroll-behavior: contain;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 44px 0 16px;
|
|
}
|
|
.lightbox img {
|
|
max-width: min(96vw, 1600px);
|
|
max-height: calc(100dvh - 72px);
|
|
object-fit: contain;
|
|
box-shadow: 0 18px 70px rgb(0 0 0 / 70%);
|
|
cursor: zoom-in;
|
|
}
|
|
.lightbox.zoomed .lightbox-stage {
|
|
place-items: start center;
|
|
}
|
|
.lightbox.zoomed img {
|
|
max-width: none;
|
|
max-height: none;
|
|
cursor: zoom-out;
|
|
}
|
|
.summary-footer {
|
|
margin-top: 24px;
|
|
padding: 18px 0 8px;
|
|
color: var(--muted);
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
@page { size: 1170px 2380px; margin: 0; }
|
|
@media print {
|
|
main { padding: 10px; }
|
|
.group { break-after: page; margin-bottom: 0; }
|
|
.group:last-child { break-after: auto; }
|
|
.summary-footer { display: none; }
|
|
.lightbox { display: none; }
|
|
}
|
|
@media (max-width: 700px) {
|
|
body { font-size: 11px; }
|
|
main { padding: 10px 6px 72px; }
|
|
.document-title { font-size: 22px; }
|
|
.group-title {
|
|
display: block;
|
|
padding: 10px 10px 12px;
|
|
}
|
|
.group-title::after {
|
|
display: block;
|
|
margin-top: 8px;
|
|
}
|
|
h1 { font-size: 18px; }
|
|
.stats { gap: 6px; }
|
|
.stat { padding: 4px 6px; }
|
|
.status-bar { margin-bottom: 0; }
|
|
.lightbox {
|
|
background: rgb(0 0 0 / 92%);
|
|
}
|
|
.lightbox-stage {
|
|
padding-top: 52px;
|
|
align-items: start;
|
|
}
|
|
.lightbox img {
|
|
max-width: calc(100vw - 20px);
|
|
max-height: calc(100dvh - 84px);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1 class="document-title">${htmlEscape(title)}</h1>
|
|
<p class="overview-note">Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.</p>
|
|
`);
|
|
|
|
const caseTemplates = [];
|
|
for (const group of groups) {
|
|
const displayGroup = groupLabels.get(group) ?? group;
|
|
const templateId = `case-template-${caseTemplates.length}`;
|
|
const groupCells = CASES.flatMap((caseName) => BACKENDS.map((backend) => cellFor(group, backend, caseName)));
|
|
const passed = groupCells.filter((row) => effectiveStatus(row) === "PASS").length;
|
|
const failed = groupCells.filter((row) => effectiveStatus(row) === "FAIL").length;
|
|
const noResult = groupCells.filter((row) => effectiveStatus(row) === "NO_RESULT").length;
|
|
const passRateDenominator = passed + failed;
|
|
const passRate = passRateDenominator === 0 ? "0.0%" : `${(passed * 100 / passRateDenominator).toFixed(1)}%`;
|
|
const statusTotal = passed + noResult + failed;
|
|
const passPercent = statusTotal === 0 ? "0" : (passed * 100 / statusTotal).toFixed(3);
|
|
const noResultPercent = statusTotal === 0 ? "0" : (noResult * 100 / statusTotal).toFixed(3);
|
|
const failPercent = statusTotal === 0 ? "0" : (failed * 100 / statusTotal).toFixed(3);
|
|
let caseHtml = `
|
|
<div class="grid header-row">
|
|
<div>case</div>
|
|
<div>DirectGLES</div>
|
|
<div>DirectVulkan</div>
|
|
</div>
|
|
`;
|
|
for (const [index, caseName] of CASES.entries()) {
|
|
caseHtml += `
|
|
<div class="grid case-row ${index % 2 === 0 ? "even" : "odd"}">
|
|
<aside>
|
|
<strong>${htmlEscape(shortCase(caseName))}</strong>
|
|
${caseName.includes("iterationt") && !rowMap.has(`${group}\0DirectGLES\0${caseName}`) && !rowMap.has(`${group}\0DirectVulkan\0${caseName}`)
|
|
? `<small>LFS fixture may be unavailable</small>` : ""}
|
|
</aside>
|
|
${backendCell(group, "DirectGLES", caseName)}
|
|
${backendCell(group, "DirectVulkan", caseName)}
|
|
</div>`;
|
|
}
|
|
caseTemplates.push({ id: templateId, html: caseHtml });
|
|
const imageTemplate = imageTemplatesByGroup.get(group);
|
|
await write(`
|
|
<section class="group collapsed" data-template-id="${templateId}" data-image-template-id="${imageTemplate ? imageTemplate.id : ""}">
|
|
<div class="group-title" role="button" tabindex="0" aria-expanded="false">
|
|
<div>
|
|
<h1>${htmlEscape(displayGroup)}</h1>
|
|
<div class="stats" aria-label="summary stats">
|
|
<span class="stat rate">PASS RATE <strong>${passRate}</strong></span>
|
|
<span class="stat-break" aria-hidden="true"></span>
|
|
<span class="stat pass">PASS <strong>${passed}</strong></span>
|
|
<span class="stat no-result">NO RESULT <strong>${noResult}</strong></span>
|
|
<span class="stat fail">FAIL <strong>${failed}</strong></span>
|
|
</div>
|
|
<div class="status-bar" aria-label="PASS ${passed}, NO RESULT ${noResult}, FAIL ${failed}">
|
|
${statusTotal === 0
|
|
? `<span class="empty" title="NO RESULT 0"></span>`
|
|
: `<span class="segment pass" style="width: ${passPercent}%;" title="PASS ${passed}"></span><span class="segment no-result" style="width: ${noResultPercent}%;" title="NO RESULT ${noResult}"></span><span class="segment fail" style="width: ${failPercent}%;" title="FAIL ${failed}"></span>`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="case-list" data-loaded="false"></div>
|
|
</section>`);
|
|
}
|
|
|
|
for (const template of caseTemplates) {
|
|
await write(`
|
|
<script type="application/json" id="${template.id}">${jsonScriptText(template.html)}</script>`);
|
|
}
|
|
|
|
await write(`
|
|
<footer class="summary-footer">End of retrace summary. ${groups.length} ${htmlEscape(groupLabel)} groups rendered.</footer>
|
|
</main>
|
|
<div class="lightbox" id="lightbox" hidden>
|
|
<button class="lightbox-close" type="button" aria-label="Close image">×</button>
|
|
<div class="lightbox-stage">
|
|
<img id="lightbox-image" alt="">
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const lightbox = document.getElementById("lightbox");
|
|
const lightboxImage = document.getElementById("lightbox-image");
|
|
const lightboxClose = document.querySelector(".lightbox-close");
|
|
const imageDataCache = new Map();
|
|
const hydrateGroup = (group) => {
|
|
const list = group.querySelector(".case-list");
|
|
if (!list || list.dataset.loaded === "true") return;
|
|
const template = document.getElementById(group.dataset.templateId);
|
|
if (!template) return;
|
|
list.innerHTML = JSON.parse(template.textContent);
|
|
list.dataset.loaded = "true";
|
|
template.remove();
|
|
};
|
|
const imageDataForGroup = (group) => {
|
|
const templateId = group?.dataset.imageTemplateId;
|
|
if (!templateId) return {};
|
|
if (imageDataCache.has(templateId)) return imageDataCache.get(templateId);
|
|
const template = document.getElementById(templateId);
|
|
if (!template) return {};
|
|
const images = JSON.parse(template.textContent);
|
|
imageDataCache.set(templateId, images);
|
|
template.remove();
|
|
return images;
|
|
};
|
|
const loadImage = (image) => {
|
|
if (!image || image.src || !image.dataset.imageId) return;
|
|
const group = image.closest(".group");
|
|
const src = imageDataForGroup(group)[image.dataset.imageId];
|
|
if (src) image.src = src;
|
|
};
|
|
const loadImagesInGroup = (group) => {
|
|
hydrateGroup(group);
|
|
group.querySelectorAll("img[data-image-id]").forEach(loadImage);
|
|
};
|
|
const closeLightbox = () => {
|
|
lightbox.hidden = true;
|
|
lightbox.classList.remove("zoomed");
|
|
lightboxImage.removeAttribute("src");
|
|
lightboxImage.alt = "";
|
|
};
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const button = event.target.closest(".image-button");
|
|
if (!button) return;
|
|
const image = button.querySelector("img");
|
|
if (!image) return;
|
|
loadImage(image);
|
|
lightboxImage.src = image.src;
|
|
lightboxImage.alt = image.alt;
|
|
lightbox.hidden = false;
|
|
lightbox.classList.remove("zoomed");
|
|
});
|
|
|
|
document.querySelectorAll(".group-title").forEach((title) => {
|
|
const toggleGroup = () => {
|
|
const group = title.closest(".group");
|
|
const expanded = group.classList.toggle("collapsed") === false;
|
|
title.setAttribute("aria-expanded", String(expanded));
|
|
if (expanded) loadImagesInGroup(group);
|
|
};
|
|
title.addEventListener("click", toggleGroup);
|
|
title.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
toggleGroup();
|
|
});
|
|
});
|
|
|
|
lightbox.addEventListener("click", closeLightbox);
|
|
lightboxClose.addEventListener("click", closeLightbox);
|
|
lightboxImage.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
lightbox.classList.toggle("zoomed");
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && !lightbox.hidden) closeLightbox();
|
|
});
|
|
window.addEventListener("load", () => {
|
|
document.querySelectorAll(".group:not(.collapsed)").forEach(loadImagesInGroup);
|
|
}, { once: true });
|
|
})();
|
|
</script>
|
|
`);
|
|
|
|
for (const template of imageTemplates) {
|
|
await write(`
|
|
<script type="application/json" id="${template.id}" data-image-data="true">${jsonScriptText(template.images)}</script>`);
|
|
}
|
|
|
|
await write(`
|
|
</body>
|
|
</html>
|
|
`);
|
|
await finish();
|
|
return output;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
await mkdir(args.outputDir, { recursive: true });
|
|
const { rows, groupLabels } = await collectRows(args.inputs, args.groupLabel);
|
|
const htmlPath = await renderHtml(rows, groupLabels, args.outputDir, args.title, args.groupLabel, args.htmlName);
|
|
console.log(htmlPath);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|