Files
workinf_Blender_Wasm/tools/web/record-re-pbr-lesson-video.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

471 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* Headed Chromium recording for the RE 5000PBR lesson video.
*
* Usage:
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
* node tools/web/record-re-pbr-lesson-video.mjs --dev --port 5184 --gpu hardware \
* --out tests/golden/RE-PBR-VIDEO-001
*
* Prepare narration first:
* python3 -I tools/web/generate-re-pbr-narration.py --mode prepare
*/
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 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 smoke = args.includes("--smoke");
const port = Number.parseInt(flag("port", "5184"), 10) || 5184;
const baseUrl = `http://127.0.0.1:${port}`;
const OUT = path.resolve(flag("out", process.env.RE_PBR_VIDEO_OUT ?? path.join(REPO, "tests/golden/RE-PBR-VIDEO-001")));
const NARRATION = path.join(OUT, "narration.json");
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",
"--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding", "--hide-scrollbars",
];
const swiftshaderArgs = [
"--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu", "--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--hide-scrollbars",
];
fs.mkdirSync(OUT, { recursive: true });
const DIAG = path.join(OUT, "diagnostics");
fs.mkdirSync(DIAG, { recursive: true });
fs.mkdirSync(path.join(LESSON, "video"), { recursive: true });
fs.mkdirSync(path.join(LESSON, "video-steps"), { recursive: true });
// Preserve prior raw Playwright recordings for diagnosis; only clear the mixed target.
fs.rmSync(path.join(OUT, "screencast.webm"), { force: true });
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const fileSha = (file) => sha256(fs.readFileSync(file));
const narration = JSON.parse(fs.readFileSync(NARRATION, "utf8"));
const durationOf = (id) => narration.chapters.find((chapter) => chapter.id === id)?.durationMs ?? 6000;
let serverExit = null;
async function waitForServer(url, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (serverExit) throw new Error(`server exited before becoming ready: ${JSON.stringify(serverExit)}`);
try { const response = await fetch(url); if (response.status < 500) return; } catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 400));
}
throw new Error(`server did not start: ${url}`);
}
function track(child) {
child.on("exit", (code, signal) => { serverExit = { code, signal }; });
child.stdout.on("data", () => undefined); child.stderr.on("data", () => undefined);
return child;
}
function startServer() {
if (useDev) {
return track(spawn(process.execPath, [path.join(WEB, "node_modules/vite/bin/vite.js"),
"--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
{ cwd: WEB, stdio: ["ignore", "pipe", "pipe"] }));
}
return track(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) } }));
}
async function installOverlay(page) {
await page.evaluate(() => {
if (!document.querySelector('link[data-lesson-video-overlay]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/tutorial/pbr/lesson/video-overlay.css";
link.dataset.lessonVideoOverlay = "";
document.head.append(link);
}
if (document.getElementById("__lesson_video_overlay")) return;
const root = document.createElement("div");
root.id = "__lesson_video_overlay";
const cursor = document.createElement("div");
cursor.id = "__lesson_video_cursor";
const caption = document.createElement("div");
caption.id = "__lesson_video_caption";
caption.textContent = "Web Blender · RE 5000PBR 渲染课";
root.append(cursor, caption);
document.body.append(root);
window.__lessonSetCaption = (text) => { caption.textContent = text; };
window.addEventListener("mousemove", (event) => {
cursor.getAnimations().forEach((animation) => animation.cancel());
cursor.animate([{ transform: `translate(${event.clientX}px, ${event.clientY}px)` }], { duration: 90, fill: "forwards", easing: "linear" });
}, { passive: true });
window.addEventListener("mousedown", (event) => {
const ripple = document.createElement("div");
ripple.className = "__lesson_video_ripple";
root.append(ripple);
ripple.animate([
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(1)`, opacity: 0.95 },
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(4.2)`, opacity: 0 },
], { duration: 520, easing: "ease-out" }).finished.then(() => ripple.remove());
}, { passive: true });
});
}
async function hideOverlay(page, hidden) {
await page.evaluate((value) => {
const root = document.getElementById("__lesson_video_overlay");
if (root) root.hidden = value;
}, hidden);
}
async function moveClick(page, locator) {
await locator.scrollIntoViewIfNeeded().catch(() => undefined);
const box = await locator.boundingBox().catch(() => null);
if (!box) { await locator.click({ force: true }); return; }
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 14 });
await page.waitForTimeout(120);
await locator.click({ force: true });
}
async function main() {
const server = startServer();
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const errors = [];
const timeline = [];
const recordingStart = Date.now();
const consoleLog = path.join(DIAG, `console-${recordingStart}.log`);
let browser;
let context;
let page;
let video;
let probe = null;
let fatalError = null;
try {
await waitForServer(`${baseUrl}/`);
browser = await chromium.launch({
headless: false, executablePath: chromePath,
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
});
context = await browser.newContext({
viewport: { width: 1600, height: 900 },
acceptDownloads: true,
recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
});
page = await context.newPage();
video = page.video();
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("crash", () => errors.push("page crashed"));
page.on("console", (message) => {
const text = message.text();
fs.appendFileSync(consoleLog, `${new Date().toISOString()} ${message.type()} ${text}\n`);
if (message.type() !== "error") return;
const location = message.location();
if (/favicon/i.test(location.url) || /Failed to load resource.*404/i.test(text)) return;
if (!/Content Security Policy|style-src/i.test(text)) errors.push(`console: ${text}`);
});
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator('main.blender-app').waitFor({ timeout: 90_000 });
await page.waitForTimeout(1500);
await installOverlay(page);
await page.waitForTimeout(500);
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");
return { vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED", version: gl.getParameter(gl.VERSION) };
}
return null;
});
if (!probe) throw new Error("no WebGL2 canvas for video probe");
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) throw new Error(`hardware GPU unavailable: ${probe.renderer}`);
const objectCount = async () => Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? "0");
const caption = async (text) => { await page.evaluate((value) => window.__lessonSetCaption?.(value), text); };
const shot = async (id) => {
await hideOverlay(page, true);
const file = path.join(LESSON, "video-steps", `${id}.jpg`);
await page.screenshot({ path: file, type: "jpeg", quality: 84 });
await hideOverlay(page, false);
return path.relative(REPO, file);
};
const waitRender = async (previousSrc, resolution) => {
await page.waitForFunction(({ previous, expected }) => {
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") !== previous && button?.textContent?.includes("渲染 PBR");
}, { previous: previousSrc, expected: resolution }, { timeout: 180_000 });
};
const render = async (resolution = 1024, camera = "SCENE") => {
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
await moveClick(page, page.getByRole("button", { name: "渲染 PBR", exact: true }));
await waitRender(previous, resolution);
return page.locator(".pbr-render-panel").getAttribute("data-texture-status");
};
const dismissDiscardDialog = async () => {
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
await dialog.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined);
if (await dialog.count()) {
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click().catch(() => undefined);
}
};
const waitStudioReady = async () => {
await page.locator('main.blender-app[data-project-id]').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 ensureRendering = async () => {
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }).catch(() => undefined);
await page.locator(".pbr-lesson-panel").waitFor({ timeout: 15_000 }).catch(() => undefined);
};
const openStudio = async () => {
await ensureRendering();
try {
const button = page.getByRole("button", { name: "载入练习摄影棚", exact: true });
const enabled = await button.isEnabled({ timeout: 5000 }).catch(() => false);
if (!enabled) throw new Error("studio button not enabled");
await moveClick(page, button);
await dismissDiscardDialog();
await Promise.race([
waitStudioReady(),
new Promise((_, reject) => setTimeout(() => reject(new Error("studio UI path timed out")), 45_000)),
]);
return;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`open-studio UI path: ${message}`);
console.error(`open-studio UI path failed: ${message}; falling back to file input`);
await page.screenshot({ path: path.join(DIAG, "open-studio-ui-failed.png") }).catch(() => undefined);
}
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator("main.blender-app").waitFor({ timeout: 90_000 });
await installOverlay(page);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await dismissDiscardDialog();
await waitStudioReady();
await ensureRendering();
};
const importGlb = async () => {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 30_000 });
await moveClick(page, page.getByRole("button", { name: "导入猴头 GLB", exact: true }));
const chooser = await chooserPromise;
await chooser.setFiles(GLB);
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
};
const chapter = async (id, title, action) => {
await caption(title);
const startMs = Date.now() - recordingStart;
try {
await action();
}
catch (error) {
const screenshot = await shot(id).catch(() => "");
await page.screenshot({ path: path.join(DIAG, `failure-${id}.png`) }).catch(() => undefined);
const state = await page.evaluate(() => ({
projectId: document.querySelector("main.blender-app")?.getAttribute("data-project-id"),
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
workspace: document.querySelector(".workspace-tab.active")?.textContent,
lessonPanels: document.querySelectorAll(".pbr-lesson-panel").length,
renderImage: document.querySelectorAll(".pbr-render-result img").length,
textureStatus: document.querySelector(".pbr-render-panel")?.getAttribute("data-texture-status"),
})).catch(() => null);
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot, error: String(error), state });
errors.push(`chapter ${id}: ${error instanceof Error ? error.stack ?? error.message : String(error)} :: ${JSON.stringify(state)}`);
throw error;
}
const waitMs = Math.max(0, startMs + durationOf(id) + 15_800 - (Date.now() - recordingStart));
if (waitMs > 0) await page.waitForTimeout(waitMs);
const screenshot = await shot(id);
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot });
console.log(`chapter ${id} ${startMs}ms-${Date.now() - recordingStart}ms`);
};
await chapter("intro", "开场:会得到什么", async () => {
await page.waitForTimeout(1200);
});
await chapter("open-studio", "载入摄影棚", async () => {
await openStudio();
});
await chapter("import-glb", "导入猴头 GLB", async () => {
await importGlb();
});
if (!smoke) {
await chapter("asset-library", "认识 Shading 与 RE 资产库", 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 });
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
await page.waitForFunction(() => document.querySelectorAll('[data-library="library:re-pbr-v1"]').length === 6, undefined, { timeout: 60_000 });
const card = page.locator('[data-library="library:re-pbr-v1"]').first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
});
await chapter("assign-materials", "分配六种材质", async () => {
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await page.locator(".pbr-lesson-picker").waitFor({ timeout: 60_000 });
for (const name of MATERIALS) {
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: name }).first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
await card.dblclick();
await page.waitForFunction((expected) => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select instanceof HTMLSelectElement && select.value.startsWith(`material:${expected}`);
}, name, { timeout: 180_000 });
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
}
});
await chapter("render-settings", "设置渲染", async () => {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
});
await chapter("render-download", "渲染与下载 PNG", async () => {
const status = await render(1024, "SCENE");
if (!/^ready:/.test(String(status))) throw new Error(`textures not ready: ${status}`);
const download = page.waitForEvent("download", { timeout: 60_000 });
await moveClick(page, page.locator(".pbr-render-download"));
const file = await download;
await file.saveAs(path.join(OUT, "video-render-1024.png"));
});
await chapter("save-reopen", "保存与重开", async () => {
const saving = page.waitForEvent("download", { timeout: 60_000 });
await moveClick(page, page.getByRole("button", { name: "保存项目", exact: true }));
const saved = await saving;
const savedPath = path.join(OUT, "lesson-video-saved.blend");
await saved.saveAs(savedPath);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(savedPath);
await page.waitForTimeout(500);
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 page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await render(1024, "SCENE");
});
await chapter("diagnostics", "诊断视图与教学曝光", async () => {
for (const mode of ["BASE_COLOR", "NORMAL", "ROUGHNESS"]) {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode);
await render(512, "SCENE");
await page.waitForTimeout(500);
}
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2");
await render(512, "SCENE");
});
await chapter("displacement", "位移与细分", async () => {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await render(1024, "SCENE");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100");
await render(1024, "SCENE");
});
await chapter("camera", "对比视角", async () => {
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON");
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT");
await render(1024, "VIEWPORT");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
});
await chapter("hq-2048", "高清 2048 贴图", async () => {
const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载|加载中/ });
await hqButton.scrollIntoViewIfNeeded();
if ((await hqButton.getAttribute("disabled")) === null) await moveClick(page, hqButton);
await page.getByRole("button", { name: "高清贴图已加载" }).waitFor({ timeout: 180_000 });
const hqSource = page.getByRole("button", { name: "高清 2048", exact: true });
await moveClick(page, hqSource);
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: "black_worn_wall_brick_HQ" }).first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
await card.dblclick();
await page.waitForFunction(() => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select instanceof HTMLSelectElement && select.value.startsWith("material:black_worn_wall_brick_HQ");
}, undefined, { timeout: 180_000 });
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
await render(1024, "SCENE");
});
await chapter("summary", "总结与教案", async () => {
await page.waitForTimeout(1200);
});
}
const durationMs = Date.now() - recordingStart;
if (!smoke) {
if (durationMs < 430_000) {
await page.waitForTimeout(430_000 - durationMs);
}
else if (durationMs > 700_000) {
throw new Error(`recording too long for the 7-12 minute contract: ${durationMs}ms`);
}
}
}
catch (error) {
fatalError = error;
errors.push(`fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
if (page) await page.screenshot({ path: path.join(DIAG, "failure-final.png") }).catch(() => undefined);
}
finally {
if (context) await context.close().catch(() => undefined);
let screencast = null;
if (video) {
const webmPath = path.join(OUT, "screencast.webm");
await video.saveAs(webmPath).catch((error) => errors.push(`video save: ${error instanceof Error ? error.message : String(error)}`));
if (fs.existsSync(webmPath)) {
screencast = { path: path.relative(REPO, webmPath), sha256: fileSha(webmPath), bytes: fs.statSync(webmPath).size };
}
}
const finalDurationMs = Date.now() - recordingStart;
const report = {
schemaVersion: 1,
status: !fatalError && !errors.length && screencast && (smoke || timeline.length === 13) ? "PASS" : "FAIL",
gpu: probe,
gpuMode,
smoke,
durationMs: finalDurationMs,
chapters: timeline,
errors,
screencast,
};
fs.writeFileSync(path.join(OUT, "timeline.json"), JSON.stringify(report, null, 2) + "\n");
fs.writeFileSync(path.join(OUT, "record-report.json"), JSON.stringify(report, null, 2) + "\n");
console.log(JSON.stringify({ status: report.status, durationMs: finalDurationMs, chapters: timeline.length, errors: errors.length, video: screencast?.path ?? null }, null, 2));
if (browser) await browser.close().catch(() => undefined);
if (server) server.kill("SIGTERM");
if (report.status !== "PASS") process.exitCode = 1;
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });