Files
workinf_Blender_Wasm/tools/web/capture-re-pbr-lesson.mjs
mes123456 d5dd647881
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- 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>
2026-11-12 21:49:24 +08:00

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;
});