#!/usr/bin/env node /** * Capture the 15-module "Web Blender full course" screenshots in headed Chromium. * * Usage: * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ * node tools/web/course/capture-full-course.mjs --dev --port 5184 --gpu hardware * * Outputs: * web/app/public/tutorial/full/steps/Mxx-*.jpg * tests/golden/COURSE-FULL-001/capture-report.json */ 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/COURSE-FULL-001"); const STEPS = path.join(REPO, "web/app/public/tutorial/full/steps"); const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend"); const GLB = path.join(REPO, "re/猴头.glb"); const TEXTURE = path.join(REPO, "web/app/public/tutorial/texture-paint/re-texture-base.png"); 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 port = Number.parseInt(flag("port", "5184"), 10) || 5184; const baseUrl = `http://127.0.0.1:${port}`; const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware"); const onlyModules = new Set(args.flatMap((value, index) => value === "--module" && args[index + 1] ? [args[index + 1]] : [])); 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", "--use-angle=swiftshader", ]; fs.mkdirSync(OUT, { recursive: true }); fs.mkdirSync(STEPS, { recursive: true }); async function waitForServer(url, timeoutMs = 60_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { 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}`); } const domClick = (locator) => locator.evaluate((element) => element.click()); async function drag(page, from, to) { const a = await from.boundingBox(); const b = await to.boundingBox(); if (!a || !b) throw new Error("drag endpoints have no box"); await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down(); await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); await page.mouse.up(); } async function main() { await waitForServer(`${baseUrl}/`); const browser = await chromium.launch({ headless: false, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs, }); const context = await browser.newContext({ viewport: { width: 1600, height: 900 }, acceptDownloads: true }); const page = await context.newPage(); const report = []; const errors = []; const shot = async (moduleId, name) => { const file = path.join(STEPS, `${moduleId}-${name}.jpg`); await page.screenshot({ path: file, type: "jpeg", quality: 82 }); return path.relative(path.join(REPO, "web/app/public/tutorial/full"), file); }; const run = async (moduleId, title, action) => { if (onlyModules.size > 0 && !onlyModules.has(moduleId)) return; const started = Date.now(); try { const images = await action(); report.push({ moduleId, title, status: "PASS", images, durationMs: Date.now() - started }); console.log(`${moduleId} PASS ${Date.now() - started}ms`); } catch (error) { const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); errors.push({ moduleId, message }); let diagnostic = null; try { diagnostic = await shot(moduleId, "error"); } catch { /* ignore */ } report.push({ moduleId, title, status: "FAIL", error: message, diagnostic, durationMs: Date.now() - started }); console.log(`${moduleId} FAIL ${message.split("\n")[0]}`); } }; const workspace = async (name) => { await domClick(page.locator(".workspace-tabs button", { hasText: name })); await page.waitForTimeout(500); }; const openStudio = async () => { await page.goto(`${baseUrl}/?ui=web`); await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); await page.locator('[data-testid="scene-stats"]').waitFor({ timeout: 120_000 }); await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); }; const ensureMonkey = async () => { const stats = await page.getByTestId("scene-stats").innerText(); if (/Objects 7/.test(stats)) return; await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); }; const enterEdit = async () => { const mode = page.locator(".mode-chip button").first(); const label = await mode.innerText().catch(() => ""); if (/Edit Mode|编辑模式/.test(label)) return; await domClick(mode); const item = page.getByRole("menuitemcheckbox", { name: /编辑模式|Edit Mode/ }).first(); await item.waitFor({ timeout: 15_000 }); await item.click(); await page.waitForTimeout(1200); }; await openStudio(); await ensureMonkey(); await run("M01", "界面、工作区、文件打开与保存", async () => { const images = [await shot("M01", "interface")]; await domClick(page.getByRole("button", { name: "文件", exact: true })); await page.waitForTimeout(400); images.push(await shot("M01", "file-menu")); await page.keyboard.press("Escape"); return images; }); await run("M02", "导航、选择与变换", async () => { await page.locator(".workspace-tabs button", { hasText: "Layout" }).first().waitFor(); const images = [await shot("M02", "layout")]; const outlinerRow = page.locator(".outliner-row").first(); if (await outlinerRow.count()) { await domClick(outlinerRow); await page.waitForTimeout(400); } images.push(await shot("M02", "selection")); const search = page.getByRole("button", { name: "操作搜索" }); await domClick(search); await page.waitForTimeout(300); await page.keyboard.type("移动"); await page.waitForTimeout(500); images.push(await shot("M02", "operator-search")); await page.keyboard.press("Escape"); return images; }); await run("M03", "网格编辑建模", async () => { await workspace("Modeling"); const images = [await shot("M03", "workspace")]; await enterEdit(); images.push(await shot("M03", "edit-mode")); const selectAll = page.getByRole("button", { name: /全选|Select All/ }).first(); if (await selectAll.count()) await domClick(selectAll); await page.waitForTimeout(400); images.push(await shot("M03", "mesh-selection")); return images; }); await run("M04", "修改器 Bevel/Boolean/Mirror/Array", async () => { await workspace("Layout"); await ensureMonkey(); const images = [await shot("M04", "properties")]; const heading = page.getByText("Modifiers", { exact: true }).first(); if (await heading.count()) { await heading.scrollIntoViewIfNeeded(); await page.waitForTimeout(400); } images.push(await shot("M04", "modifier-stack")); return images; }); await run("M05", "材质与 Shader/PBR 资产库", async () => { await workspace("Shading"); await page.waitForTimeout(800); const images = [await shot("M05", "shader-workspace")]; const library = page.getByText(/PBR 资产库|PBR Library/).first(); if (await library.count()) { await library.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); } images.push(await shot("M05", "asset-library")); return images; }); await run("M06", "UV 展开与贴图准备", async () => { await workspace("UV Editing"); await page.locator(".wasm-uv-editor").waitFor({ timeout: 60_000 }); await page.locator(".uv-controls").waitFor({ timeout: 60_000 }); await enterEdit(); await page.keyboard.press("Escape"); const images = [await shot("M06", "uv-workspace")]; const uv = page.locator(".wasm-uv-editor"); const selectAll = uv.locator("button", { hasText: "全选 UV" }).first(); if (await selectAll.count()) { await domClick(selectAll); await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("UV 选择已提交"), null, { timeout: 60_000 }).catch(() => undefined); } const unwrap = uv.locator("button", { hasText: "展开所选 UV" }).first(); if (await unwrap.count()) { await domClick(unwrap); await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("展开 UV已提交"), null, { timeout: 60_000 }).catch(() => undefined); } images.push(await shot("M06", "unwrap")); return images; }); await run("M07", "Texture Paint 手绘贴图", async () => { await workspace("Shading"); await page.locator(".pbr-asset-library").waitFor({ timeout: 60_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, null, { timeout: 60_000 }); const material = page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first(); if (await material.count()) { await material.dblclick(); await page.waitForFunction(() => { const slot = document.querySelector('select[aria-label^="材质槽"]'); return slot instanceof HTMLSelectElement && slot.value.includes("material:black_tiles_wall"); }, null, { timeout: 120_000 }); } const fileInput = page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first(); if (await fileInput.count()) { await fileInput.setInputFiles(TEXTURE); await page.waitForFunction(() => Array.from(document.querySelectorAll('select[aria-label="材质图片"] option')).some((option) => option.textContent?.includes("re-texture-base")), null, { timeout: 60_000 }); } const materialImage = page.locator('select[aria-label="材质图片"]').first(); if (await materialImage.count()) await materialImage.selectOption({ label: "re-texture-base" }).catch(() => undefined); await workspace("Texture Paint"); const image = page.locator('[data-testid="texture-paint-canvas"]').first(); await image.waitFor({ timeout: 60_000 }).catch(() => undefined); const textureSelect = page.locator('.texture-paint-view select[aria-label="纹理图像"]').first(); if (await textureSelect.count()) { const options = await textureSelect.locator("option").allTextContents(); const match = options.find((text) => text.includes("re-texture-base")); if (match) await textureSelect.selectOption({ label: match }).catch(() => undefined); await page.waitForTimeout(1200); } const images = [await shot("M07", "texture-paint")]; if (await image.count()) { const box = await image.boundingBox(); if (box) { await page.mouse.move(box.x + box.width * 0.35, box.y + box.height * 0.45); await page.mouse.down(); await page.mouse.move(box.x + box.width * 0.62, box.y + box.height * 0.55, { steps: 18 }); await page.mouse.up(); await page.waitForTimeout(2000); } } images.push(await shot("M07", "painted-stroke")); return images; }); await run("M08", "灯光、相机与 PBR 渲染", async () => { await workspace("Rendering"); await page.waitForTimeout(1000); const images = [await shot("M08", "render-workspace")]; await page.getByText("PBR 渲染练习", { exact: false }).first().scrollIntoViewIfNeeded().catch(() => undefined); await page.waitForTimeout(400); images.push(await shot("M08", "pbr-panel")); return images; }); await run("M09", "渲染结果、诊断与 GLB 导出", async () => { await workspace("Rendering"); const render = page.getByRole("button", { name: /渲染 PBR|Render PBR/ }).first(); const images = []; if (await render.count()) { await domClick(render); await page.waitForTimeout(8000); } images.push(await shot("M09", "render-result")); return images; }); await run("M10", "时间线与关键帧动画", async () => { await workspace("Animation"); await page.waitForTimeout(800); const images = [await shot("M10", "animation-workspace")]; const key = page.getByRole("button", { name: "插入位置关键帧", exact: true }).first(); if (await key.count()) { await domClick(key); await page.waitForTimeout(800); } images.push(await shot("M10", "keyframe")); return images; }); await run("M11", "Compositor 合成节点", async () => { await workspace("Compositing"); const canvas = page.getByTestId("compositor-canvas"); await canvas.waitFor({ timeout: 60_000 }); const images = [await shot("M11", "compositor")]; const addType = canvas.getByLabel("添加节点类型"); await addType.selectOption("CONSTANT_COLOR"); await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); await page.waitForTimeout(400); await addType.selectOption("EXPOSURE"); await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); await page.waitForTimeout(500); images.push(await shot("M11", "nodes-added")); const source = page.locator('.react-flow__node[data-id="node-constant-color-1"] .react-flow__handle.source').first(); const target = page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.target').first(); if (await source.count() && await target.count()) await drag(page, source, target); const target2 = page.locator('.react-flow__node[data-id="node-composite"] .react-flow__handle.target').first(); const source2 = page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.source').first(); if (await source2.count() && await target2.count()) await drag(page, source2, target2); await page.waitForTimeout(300); await domClick(canvas.getByRole("button", { name: "执行合成", exact: true })); await page.waitForTimeout(800); images.push(await shot("M11", "executed")); return images; }); await run("M12", "Geometry Nodes 程序化建模", async () => { await workspace("Geometry Nodes"); const canvas = page.getByTestId("geometry-node-canvas"); await canvas.waitFor({ timeout: 60_000 }); const images = [await shot("M12", "geometry-nodes")]; await canvas.getByLabel("添加节点类型").selectOption({ index: 2 }).catch(() => undefined); const add = canvas.getByRole("button", { name: "添加节点", exact: true }); if (await add.count()) { await domClick(add); await page.waitForTimeout(500); } images.push(await shot("M12", "node-added")); return images; }); await run("M13", "Spreadsheet 数据检查", async () => { await workspace("Geometry Nodes"); const sheet = page.getByTestId("spreadsheet-view"); await sheet.waitFor({ timeout: 60_000 }); const images = [await shot("M13", "objects")]; await sheet.getByLabel("数据源").selectOption("vertices"); await page.waitForTimeout(600); images.push(await shot("M13", "vertices")); await sheet.getByLabel("过滤行").fill("0.5"); await page.waitForTimeout(400); images.push(await shot("M13", "filtered")); await sheet.getByLabel("过滤行").fill(""); return images; }); await run("M14", "保存、重开与异常恢复", async () => { await domClick(page.locator('button[aria-label="保存项目"]')); await page.waitForTimeout(2500); const images = [await shot("M14", "opfs-save")]; await page.reload(); await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); await page.waitForTimeout(1200); images.push(await shot("M14", "reopen")); return images; }); await run("M15", "脚本、支持矩阵与能力边界", async () => { await workspace("Scripting"); await page.waitForTimeout(1200); const images = [await shot("M15", "scripting")]; const python = page.getByText("Python Console", { exact: false }).first(); if (await python.count()) await python.scrollIntoViewIfNeeded().catch(() => undefined); images.push(await shot("M15", "console")); return images; }); await context.close(); await browser.close(); fs.writeFileSync(path.join(OUT, "capture-report.json"), JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), gpuMode, modules: report, errors }, null, 2) + "\n", "utf8"); console.log(JSON.stringify({ status: errors.length === 0 ? "PASS" : "PARTIAL", modules: report.length, failures: errors.length, report: path.join(OUT, "capture-report.json") }, null, 2)); process.exitCode = errors.length === 0 ? 0 : 1; } main().catch((error) => { console.error(error); process.exit(1); });