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>
This commit is contained in:
356
tools/web/capture-re-pbr-lesson.mjs
Normal file
356
tools/web/capture-re-pbr-lesson.mjs
Normal file
@@ -0,0 +1,356 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user