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