Add local glTF viewer and visual coverage

Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-24 10:51:26 +00:00
co-authored by heaust
parent df8b69e09d
commit 951449db0b
99 changed files with 4004 additions and 376 deletions
+391
View File
@@ -0,0 +1,391 @@
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { createServer } from "node:net";
import {
access,
copyFile,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
stat,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { after, before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execute = promisify(execFile);
const directory = dirname(fileURLToPath(import.meta.url));
const root = resolve(directory, "../../../..");
const baselines = join(directory, "baselines");
const output = resolve(
process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"),
);
const revision = "d7a3cc8e51d7c573771ae77a57f16b0662a905c6";
const fixtureDirectory = resolve(
process.env.YAWN_GLTF_FIXTURES ??
join(homedir(), ".cache", "yawn", "gltf-sample-models", revision),
);
const selected = new Set(
(process.env.YAWN_SCREENSHOT_MODELS ?? "")
.split(",")
.map((name) => name.trim())
.filter(Boolean),
);
const update = process.env.UPDATE_SCREENSHOTS === "1";
const session = `yawn-gltf-screenshots-${process.pid}`;
const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-"));
const imageSize = { width: 250, height: 180 };
const allModels = (await readdir(baselines))
.filter((name) => name.endsWith(".png") && name !== "initial.png")
.map((name) => basename(name, ".png"))
.sort()
.map((name) => ({
name,
source: name === "XmpMetadataRoundedCube" ? "xmp.glb" : `${name}.glb`,
path: join(fixtureDirectory, `${name}.glb`),
}));
const models = allModels.filter(
({ name }) => selected.size === 0 || selected.has(name),
);
let serverProcess;
let serverOutput = "";
let url = process.env.YAWN_VISUAL_URL?.replace(/\/+$/u, "");
function run(name, args, options = {}) {
return execute(name, args, {
cwd: root,
maxBuffer: 10 * 1024 * 1024,
timeout: 120_000,
...options,
});
}
async function browser(...args) {
const { stdout } = await run(
process.env.AGENT_BROWSER_BIN ?? "agent-browser",
["--session", session, "--json", ...args],
);
const result = JSON.parse(stdout.trim());
if (!result.success) throw new Error(result.error ?? "agent-browser failed");
return result.data;
}
async function availablePort() {
const server = createServer();
await new Promise((accept, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", accept);
});
const address = server.address();
assert(address && typeof address !== "string");
await new Promise((accept, reject) =>
server.close((error) => (error ? reject(error) : accept())),
);
return address.port;
}
async function startServer() {
if (url) return;
const binary = join(temporary, "yawn-server");
await run("go", ["build", "-o", binary, "./server"]);
const port = await availablePort();
serverProcess = spawn(binary, [], {
cwd: root,
env: {
...process.env,
PORT: String(port),
YAWN_DATABASE: join(temporary, "yawn.db"),
},
stdio: ["ignore", "pipe", "pipe"],
});
const record = (chunk) => {
serverOutput = `${serverOutput}${chunk}`.slice(-20_000);
};
serverProcess.stdout.on("data", record);
serverProcess.stderr.on("data", record);
url = `http://localhost:${port}`;
const deadline = Date.now() + 600_000;
while (Date.now() < deadline) {
if (serverProcess.exitCode !== null)
throw new Error(`Yawn server exited early:\n${serverOutput}`);
try {
if ((await fetch(`${url}/healthz`)).status === 204) return;
} catch {
// The package build happens before the server begins listening.
}
await new Promise((accept) => setTimeout(accept, 250));
}
throw new Error(`Yawn server did not become ready:\n${serverOutput}`);
}
async function validGlb(path) {
try {
const info = await stat(path);
if (info.size < 12) return false;
const file = await open(path, "r");
try {
const header = Buffer.alloc(12);
await file.read(header, 0, header.length, 0);
return (
header.toString("ascii", 0, 4) === "glTF" &&
header.readUInt32LE(4) === 2 &&
header.readUInt32LE(8) === info.size
);
} finally {
await file.close();
}
} catch {
return false;
}
}
async function download(model) {
if (await validGlb(model.path)) return;
await mkdir(fixtureDirectory, { recursive: true });
const part = `${model.path}.${process.pid}.part`;
const source = new URL(
`${encodeURIComponent(model.name)}/glTF-Binary/${encodeURIComponent(model.source)}`,
`https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/${revision}/2.0/`,
);
process.stdout.write(`Downloading ${model.name}…\n`);
const response = await fetch(source);
if (!response.ok || !response.body)
throw new Error(`Could not download ${source}: HTTP ${response.status}`);
try {
await pipeline(Readable.fromWeb(response.body), createWriteStream(part));
} catch (error) {
await rm(part, { force: true });
throw error;
}
if (!(await validGlb(part))) {
await rm(part, { force: true });
throw new Error(`${model.name} did not download as a valid GLB`);
}
await rename(part, model.path);
}
async function prepareModels() {
let next = 0;
await Promise.all(
Array.from({ length: Math.min(4, models.length) }, async () => {
while (next < models.length) await download(models[next++]);
}),
);
}
async function imageBytes(path) {
const raw = join(temporary, `${basename(path)}-${Math.random()}.rgb`);
await run("magick", [
path,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
"-depth",
"8",
`rgb:${raw}`,
]);
const bytes = await readFile(raw);
await rm(raw, { force: true });
return bytes;
}
async function compareScreenshot(name, actual) {
const expected = join(baselines, `${name}.png`);
if (update) {
await run("magick", [
actual,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
expected,
]);
return;
}
await access(expected);
const [actualBytes, expectedBytes] = await Promise.all([
imageBytes(actual),
imageBytes(expected),
]);
assert.equal(actualBytes.length, expectedBytes.length);
let absoluteError = 0;
let differentPixels = 0;
for (let index = 0; index < actualBytes.length; index += 3) {
let squaredError = 0;
for (let channel = 0; channel < 3; channel++) {
const difference = Math.abs(
actualBytes[index + channel] - expectedBytes[index + channel],
);
absoluteError += difference;
squaredError += difference * difference;
}
if (Math.sqrt(squaredError / 3) / 255 > 0.1) differentPixels++;
}
const meanError = absoluteError / actualBytes.length / 255;
const differentRatio = differentPixels / (actualBytes.length / 3);
if (meanError <= 0.01 && differentRatio <= 0.02) return;
await mkdir(output, { recursive: true });
const saved = join(output, `${name}-actual.png`);
const difference = join(output, `${name}-diff.png`);
await copyFile(actual, saved);
await run("magick", [
expected,
"(",
actual,
"-filter",
"box",
"-resize",
`${imageSize.width}x${imageSize.height}!`,
")",
"-compose",
"difference",
"-composite",
"-auto-level",
"-filter",
"point",
"-resize",
"640x460",
difference,
]);
assert.fail(
`${name} differs from its reference: mean error ${(meanError * 100).toFixed(2)}%, ` +
`${(differentRatio * 100).toFixed(2)}% changed pixels; see ${saved}`,
);
}
async function capture(name) {
const path = join(temporary, `${name}.png`);
await browser("screenshot", path);
await compareScreenshot(name, path);
}
async function diagnostics() {
const { errors } = await browser("errors");
const { messages } = await browser("console");
assert.deepEqual(errors, []);
assert.deepEqual(messages, []);
await browser("errors", "--clear");
await browser("console", "--clear");
}
async function saveFailure(name) {
try {
await mkdir(output, { recursive: true });
await browser("screenshot", join(output, `${name}-failure.png`));
} catch {
// Preserve the original test failure if the browser cannot capture.
}
}
before(
async () => {
assert.equal(
allModels.length,
72,
"expected all 72 reviewed GLB baselines",
);
assert.equal(
selected.size === 0 || models.length === selected.size,
true,
"YAWN_SCREENSHOT_MODELS contains an unknown model",
);
await prepareModels();
await startServer();
await browser("--webgpu", "--headed", "open", `${url}/view/gltf`);
await browser("set", "viewport", "1000", "720", "1");
},
{ timeout: 900_000 },
);
after(async () => {
try {
await browser("close");
} catch {
// The browser may already be gone after an initialization failure.
}
if (serverProcess && serverProcess.exitCode === null) serverProcess.kill();
await rm(temporary, { force: true, recursive: true });
});
test("viewer starts with the local-file prompt", async () => {
const state = await browser(
"eval",
`({
isolated: crossOriginIsolated,
webgpu: Boolean(navigator.gpu),
promptHidden: document.querySelector("#drop-prompt").hidden,
prompt: document.querySelector("#prompt-message").textContent,
renderHidden: document.querySelector("#render-time").hidden
})`,
);
assert.deepEqual(state.result, {
isolated: true,
webgpu: true,
promptHidden: false,
prompt: "Drop a glTF file",
renderHidden: true,
});
await diagnostics();
await capture("initial");
});
for (const model of models) {
test(
`renders ${model.name}`,
async () => {
try {
await browser("errors", "--clear");
await browser("console", "--clear");
await browser(
"eval",
`(() => {
window.__yawnScreenshotReady = false;
const prompt = document.querySelector("#drop-prompt");
const observer = new MutationObserver(() => {
if (prompt.hidden) {
window.__yawnScreenshotReady = true;
observer.disconnect();
}
});
observer.observe(prompt, { attributes: true, attributeFilter: ["hidden"] });
})()`,
);
await browser("upload", "#file-picker", model.path);
await browser("wait", "--fn", "window.__yawnScreenshotReady === true");
await browser("wait", "1200");
const state = await browser(
"eval",
`({
promptHidden: document.querySelector("#drop-prompt").hidden,
renderHidden: document.querySelector("#render-time").hidden,
render: document.querySelector("#render-value").textContent
})`,
);
assert.equal(state.result.promptHidden, true);
assert.equal(state.result.renderHidden, false);
assert.match(state.result.render, /^(?:—|\d+(?:\.\d+)?) ms$/u);
await diagnostics();
await capture(model.name);
} catch (error) {
await saveFailure(model.name);
throw error;
}
},
{ timeout: 120_000 },
);
}