#!/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; });