- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots. - Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060. - Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine. - Harden lesson video recording pipeline; fix stale lesson-link E2E selector. - Add RE PBR derived libraries, lesson page, evidence and status report. Co-Authored-By: Claude Code <noreply@anthropic.com>
357 lines
16 KiB
JavaScript
357 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Headed Chromium capture for the RE 5000PBR + monkey-head render lesson.
|
|
*
|
|
* Usage:
|
|
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
|
|
* node tools/web/capture-re-pbr-lesson.mjs --dev --port 5177 --mode all
|
|
*
|
|
* Produces:
|
|
* tests/golden/RE-PBR-RENDER-001/renders/*.png
|
|
* tests/golden/RE-PBR-RENDER-001/report.json
|
|
* web/app/public/tutorial/pbr/lesson/renders/*.jpg
|
|
* web/app/public/tutorial/pbr/lesson/steps/*.jpg
|
|
* web/app/public/tutorial/pbr/lesson/landing.jpg
|
|
*/
|
|
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const WEB = path.join(REPO, "web");
|
|
const requireFromWeb = createRequire(path.join(WEB, "package.json"));
|
|
const { chromium } = requireFromWeb("playwright");
|
|
const OUT = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
|
|
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
|
|
const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend");
|
|
const GLB = path.join(REPO, "re/猴头.glb");
|
|
const MATERIALS = [
|
|
"Black_Anti_Slip_grip",
|
|
"Blackish_Brick_Wall_016",
|
|
"black_randome_tile",
|
|
"black_shinning_tile",
|
|
"black_tiles_wall",
|
|
"black_worn_wall_brick",
|
|
];
|
|
|
|
const args = process.argv.slice(2);
|
|
const flag = (name, fallback) => {
|
|
const index = args.indexOf(`--${name}`);
|
|
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
|
};
|
|
const useDev = args.includes("--dev");
|
|
const port = Number.parseInt(flag("port", process.env.WEB_TEST_PORT ?? "5177"), 10) || 5177;
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const mode = flag("mode", "all");
|
|
const gpuMode = flag("gpu", process.env.GPU_MODE ?? (process.env.DISPLAY ? "hardware" : "swiftshader"));
|
|
const hardwareArgs = [
|
|
"--no-sandbox",
|
|
"--use-gl=angle",
|
|
"--use-angle=gl",
|
|
"--ignore-gpu-blocklist",
|
|
"--enable-gpu-rasterization",
|
|
"--enable-zero-copy",
|
|
"--enable-unsafe-webgpu",
|
|
];
|
|
const swiftshaderArgs = [
|
|
"--no-sandbox",
|
|
"--use-gl=swiftshader",
|
|
"--enable-unsafe-swiftshader",
|
|
"--enable-unsafe-webgpu",
|
|
"--enable-dawn-features=allow_unsafe_apis",
|
|
"--use-webgpu-adapter=swiftshader",
|
|
];
|
|
|
|
fs.mkdirSync(path.join(OUT, "renders"), { recursive: true });
|
|
fs.mkdirSync(path.join(LESSON, "renders"), { recursive: true });
|
|
fs.mkdirSync(path.join(LESSON, "steps"), { recursive: true });
|
|
|
|
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
const sha256File = (file) => sha256(fs.readFileSync(file));
|
|
|
|
async function waitForServer(url, timeoutMs = 60_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url, { redirect: "manual" });
|
|
if (response.status < 500) return;
|
|
}
|
|
catch { /* retry */ }
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
}
|
|
throw new Error(`server did not start: ${url}`);
|
|
}
|
|
|
|
function startServer() {
|
|
if (useDev) {
|
|
const child = spawn(process.execPath, [
|
|
path.join(WEB, "node_modules/vite/bin/vite.js"),
|
|
"--config", path.join(WEB, "app/vite.config.ts"),
|
|
"--host", "127.0.0.1", "--port", String(port), "--strictPort",
|
|
], { cwd: WEB, stdio: ["ignore", "pipe", "pipe"] });
|
|
child.stdout.on("data", () => undefined);
|
|
child.stderr.on("data", () => undefined);
|
|
return child;
|
|
}
|
|
const child = spawn(process.execPath, [path.join(REPO, "tools/web/deployment-http-server.mjs"), "dist"], {
|
|
cwd: WEB, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) },
|
|
});
|
|
child.stdout.on("data", () => undefined);
|
|
child.stderr.on("data", () => undefined);
|
|
return child;
|
|
}
|
|
|
|
const consoleErrors = [];
|
|
|
|
async function main() {
|
|
const server = startServer();
|
|
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
|
|
let browser;
|
|
try {
|
|
await waitForServer(`${baseUrl}/`);
|
|
browser = await chromium.launch({
|
|
headless: false,
|
|
executablePath: chromePath,
|
|
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
|
|
});
|
|
let context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
|
let page = await context.newPage();
|
|
const attachPage = () => {
|
|
page.on("pageerror", (error) => consoleErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() !== "error") return;
|
|
const text = message.text();
|
|
if (!/Content Security Policy|style-src/i.test(text)) consoleErrors.push(text);
|
|
});
|
|
};
|
|
attachPage();
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
task: "RE-PBR-RENDER-001",
|
|
renderer: "WEB_WEBGL2",
|
|
headed: true,
|
|
gpuMode,
|
|
browser: browser.version(),
|
|
chromePath,
|
|
display: process.env.DISPLAY ?? "",
|
|
generatedAt: new Date().toISOString(),
|
|
studioSha256: sha256File(STUDIO),
|
|
glbSha256: sha256File(GLB),
|
|
materials: MATERIALS,
|
|
renders: [],
|
|
steps: [],
|
|
errors: [],
|
|
};
|
|
|
|
const probeGpu = async () => {
|
|
const probe = await page.evaluate(async () => {
|
|
for (const canvas of document.querySelectorAll("canvas")) {
|
|
const gl = canvas.getContext("webgl2");
|
|
if (!gl) continue;
|
|
const debug = gl.getExtension("WEBGL_debug_renderer_info");
|
|
const webgpu = navigator.gpu
|
|
? await navigator.gpu.requestAdapter().then((adapter) => adapter
|
|
? { isFallbackAdapter: adapter.isFallbackAdapter, info: adapter.info ? { ...adapter.info } : null }
|
|
: null).catch(() => null)
|
|
: null;
|
|
return {
|
|
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED",
|
|
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED",
|
|
version: gl.getParameter(gl.VERSION),
|
|
webgpu,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
if (!probe) throw new Error("GPU probe: no WebGL2 canvas found");
|
|
report.webgl = { vendor: probe.vendor, renderer: probe.renderer, version: probe.version };
|
|
report.webgpu = probe.webgpu;
|
|
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) {
|
|
throw new Error(`GPU probe failed: hardware mode got ${probe.renderer}`);
|
|
}
|
|
return probe;
|
|
};
|
|
|
|
const objectCount = async () => {
|
|
const text = await page.getByTestId("scene-stats").innerText();
|
|
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
|
|
};
|
|
const openStudio = async () => {
|
|
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
|
await page.locator(".blender-app").waitFor({ timeout: 90_000 });
|
|
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
|
|
await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
const tools = page.locator('[data-testid="native-web-tools-toggle"]');
|
|
if (await tools.count()) await tools.click();
|
|
};
|
|
const importMonkey = async () => {
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
|
|
try {
|
|
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
}
|
|
catch (error) {
|
|
await page.screenshot({ path: path.join(OUT, "failure-import.png"), type: "png" }).catch(() => undefined);
|
|
const state = await page.evaluate(() => ({
|
|
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
|
|
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
|
|
body: document.body.innerText.slice(0, 600),
|
|
})).catch(() => null);
|
|
console.error("IMPORT_FAIL", JSON.stringify({ state, consoleErrors: consoleErrors.slice(-8) }));
|
|
throw error;
|
|
}
|
|
};
|
|
const openShading = async () => {
|
|
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
|
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
|
|
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 90_000 });
|
|
};
|
|
const openRendering = async () => {
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
await page.locator(".pbr-render-panel").waitFor({ timeout: 60_000 });
|
|
};
|
|
const assign = async (name) => {
|
|
const card = page.locator(`[data-library="library:re-pbr-v1"]`, { hasText: name }).first();
|
|
await card.waitFor({ timeout: 60_000 });
|
|
await card.dblclick();
|
|
await page.waitForFunction((expected) => {
|
|
const select = document.querySelector('select[aria-label^="材质槽"]');
|
|
return select instanceof HTMLSelectElement && select.value === `material:${expected}`;
|
|
}, name, { timeout: 180_000 });
|
|
await page.waitForFunction(() => document.querySelector(".pbr-asset-grid")?.getAttribute("aria-busy") === "false", undefined, { timeout: 180_000 });
|
|
};
|
|
const render = async (resolution, camera, name, compileStatus = "") => {
|
|
await openRendering();
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
|
|
await page.locator('select[aria-label="PBR 视角预设"]').selectOption(camera === "VIEWPORT" ? "COMPARISON" : "SCENE_CAMERA");
|
|
const previousSrc = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await page.waitForFunction(({ expected, previousSrc }) => {
|
|
const image = document.querySelector(".pbr-render-result img");
|
|
const button = [...document.querySelectorAll(".pbr-render-panel button")]
|
|
.find((candidate) => /渲染 PBR|渲染中/.test(candidate.textContent ?? ""));
|
|
return image instanceof HTMLImageElement
|
|
&& image.naturalWidth === expected
|
|
&& image.getAttribute("src") !== previousSrc
|
|
&& button?.textContent?.includes("渲染 PBR");
|
|
}, { expected: resolution, previousSrc }, { timeout: 180_000 });
|
|
const panel = page.locator(".pbr-render-panel");
|
|
const textureStatus = await panel.getAttribute("data-texture-status");
|
|
const textureAssets = Number(await panel.getAttribute("data-texture-assets") ?? "0");
|
|
const stats = await page.evaluate(() => {
|
|
const image = document.querySelector(".pbr-render-result img");
|
|
if (!(image instanceof HTMLImageElement)) throw new Error("render image missing");
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context) throw new Error("2d canvas unavailable");
|
|
context.drawImage(image, 0, 0);
|
|
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let sum = 0;
|
|
let sumSquared = 0;
|
|
let count = 0;
|
|
for (let index = 0; index < data.length; index += 4) {
|
|
const luminance = (data[index] + data[index + 1] + data[index + 2]) / 3;
|
|
sum += luminance;
|
|
sumSquared += luminance * luminance;
|
|
count += 1;
|
|
}
|
|
const mean = sum / Math.max(1, count);
|
|
return { mean, std: Math.sqrt(Math.max(0, sumSquared / Math.max(1, count) - mean * mean)), jpeg: canvas.toDataURL("image/jpeg", 0.86) };
|
|
});
|
|
const pngPath = path.join(OUT, "renders", `${name}.png`);
|
|
const [download] = await Promise.all([page.waitForEvent("download"), page.locator(".pbr-render-download").click()]);
|
|
await download.saveAs(pngPath);
|
|
const jpgPath = path.join(LESSON, "renders", `${name}.jpg`);
|
|
fs.writeFileSync(jpgPath, Buffer.from(stats.jpeg.split(",")[1], "base64"));
|
|
const entry = {
|
|
name, material: name.startsWith("material-") ? name.slice("material-".length) : null,
|
|
resolution, camera, path: path.relative(REPO, pngPath), jpg: path.relative(REPO, jpgPath),
|
|
sha256: sha256File(pngPath), byteLength: fs.statSync(pngPath).size,
|
|
mean: Number(stats.mean.toFixed(4)), std: Number(stats.std.toFixed(4)),
|
|
textureStatus, textureAssets, compileStatus,
|
|
};
|
|
report.renders.push(entry);
|
|
return entry;
|
|
};
|
|
const step = async (name) => {
|
|
const file = path.join(LESSON, "steps", `${name}.jpg`);
|
|
await page.screenshot({ path: file, type: "jpeg", quality: 82 });
|
|
report.steps.push({ name, path: path.relative(REPO, file), sha256: sha256File(file) });
|
|
};
|
|
|
|
// Baseline: studio + imported monkey, no library material.
|
|
await openStudio();
|
|
await probeGpu();
|
|
await step("01-open-studio");
|
|
await importMonkey();
|
|
await step("02-import-glb");
|
|
await render(1024, "SCENE", "baseline-unassigned-1024-scene", "BASELINE");
|
|
|
|
for (const name of MATERIALS) {
|
|
await context.close();
|
|
context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
|
page = await context.newPage();
|
|
attachPage();
|
|
await openStudio();
|
|
await importMonkey();
|
|
await openShading();
|
|
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
|
|
await step(`03-asset-library-${name}`);
|
|
await assign(name);
|
|
await page.waitForFunction(() => /COMPILED|BLOCKED/.test(document.querySelector(".material-preview-status")?.textContent ?? ""), undefined, { timeout: 60_000 });
|
|
const compileText = (await page.locator(".material-preview-status").innerText({ timeout: 60_000 })).trim();
|
|
const compileStatus = compileText.split("\n").find((line) => /COMPILED|BLOCKED/.test(line))?.trim() ?? compileText;
|
|
await step(`04-assign-${name}`);
|
|
await render(1024, "SCENE", `material-${name}-1024-scene`, compileStatus);
|
|
await render(1024, "VIEWPORT", `material-${name}-1024-viewport`, compileStatus);
|
|
await render(512, "SCENE", `material-${name}-512-scene`, compileStatus);
|
|
}
|
|
|
|
// Saved/reopened project re-render (last material persists in the .blend).
|
|
const saved = path.join(OUT, "re-pbr-lesson-saved.blend");
|
|
await openRendering();
|
|
const [blendDownload] = await Promise.all([page.waitForEvent("download"), page.getByRole("button", { name: "保存项目", exact: true }).click()]);
|
|
await blendDownload.saveAs(saved);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(saved);
|
|
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
|
|
if (await dialog.count()) {
|
|
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
|
|
if (await discard.count()) await discard.click();
|
|
}
|
|
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
|
|
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
await step("05-reopen");
|
|
await render(1024, "SCENE", `material-${MATERIALS[MATERIALS.length - 1]}-reopen-1024-scene`, "REOPENED");
|
|
|
|
await page.screenshot({ path: path.join(LESSON, "landing.jpg"), type: "jpeg", quality: 84 });
|
|
|
|
report.status = report.renders.length === 20 && report.errors.length === 0 ? "PASS" : "FAIL";
|
|
report.consoleErrors = consoleErrors;
|
|
fs.writeFileSync(path.join(OUT, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(JSON.stringify({
|
|
status: report.status,
|
|
renders: report.renders.length,
|
|
steps: report.steps.length,
|
|
errors: report.errors.length + consoleErrors.length,
|
|
report: path.relative(REPO, path.join(OUT, "report.json")),
|
|
}, null, 2));
|
|
if (report.status !== "PASS") process.exitCode = 1;
|
|
}
|
|
finally {
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
server.kill("SIGTERM");
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|