#!/usr/bin/env node /** * Capture the screenshots referenced by the Web Blender user manual. * * Usage: * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ * node tools/web/manual/capture-manual.mjs --port 5184 --gpu hardware */ import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO = path.resolve(HERE, "../../.."); const WEB = path.join(REPO, "web"); const requireFromWeb = createRequire(path.join(WEB, "package.json")); const { chromium } = requireFromWeb("playwright"); const OUT = path.join(WEB, "app/public/manuals/images"); const STUDIO = path.join(WEB, "app/public/tutorial/pbr/studio.blend"); const GLB = path.join(REPO, "re/猴头.glb"); const REPORT = path.join(REPO, "tests/golden/USER-MANUAL-001/capture-report.json"); 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 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"]; async function waitForServer() { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { try { const response = await fetch(baseUrl); if (response.status < 500) return; } catch { /* retry */ } await new Promise((resolve) => setTimeout(resolve, 400)); } throw new Error(`server unavailable: ${baseUrl}`); } const domClick = (locator) => locator.evaluate((element) => element.click()); async function main() { await waitForServer(); fs.mkdirSync(OUT, { recursive: true }); fs.mkdirSync(path.dirname(REPORT), { recursive: true }); const browser = await chromium.launch({ headless: false, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs }); const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); const images = []; const failures = []; const shot = async (name) => { const file = path.join(OUT, `${name}.jpg`); await page.screenshot({ path: file, type: "jpeg", quality: 82 }); images.push({ name, file: path.relative(REPO, file) }); console.log(`captured ${name}`); }; const closeMenu = async () => { await page.keyboard.press("Escape"); await page.waitForTimeout(200); }; try { await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" }); await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); 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 }); await page.waitForTimeout(900); await domClick(page.locator('button[aria-label="保存项目"]')); await page.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("data-user-action-save-status") === "SUCCEEDED", null, { timeout: 60_000 }); await shot("ch01-first-save"); await page.getByRole("button", { name: "文件", exact: true }).click(); await page.getByRole("menuitem", { name: "最近打开", exact: true }).click(); await page.waitForTimeout(400); await shot("ch03-recent"); await closeMenu(); await page.getByRole("button", { name: "文件", exact: true }).click(); await page.getByRole("menuitem", { name: "恢复", exact: true }).click(); await page.getByRole("menuitem", { name: "恢复浏览器恢复点", exact: true }).click(); await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).waitFor({ timeout: 30_000 }); await shot("ch05-recovery"); await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).getByRole("button", { name: "取消", exact: true }).click(); await page.waitForTimeout(300); const properties = page.locator(".properties-area"); await properties.locator(".area-type-switch .area-menu button").evaluate((element) => element.click()); await page.getByRole("menu").last().waitFor({ timeout: 10_000 }); await shot("ch06-area-menu"); await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click(); await page.waitForTimeout(500); await shot("ch06-area-split"); await page.locator(".workspace-grid > .area-frame .area-type-switch .area-menu button").first().evaluate((element) => element.click()); await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click(); await page.waitForTimeout(400); const canvas = page.locator(".viewport-area canvas.viewport-canvas").first(); await canvas.focus(); await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click()); await page.waitForTimeout(700); await shot("ch07-camera-view"); await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click()); await page.waitForTimeout(300); const row = page.locator(".tree-row.child, [role=treeitem]").first(); if (await row.count()) await row.evaluate((element) => element.click()); await page.waitForTimeout(400); await shot("ch08-outliner"); await properties.locator('button[title="修改器属性"]').evaluate((element) => element.click()); await page.waitForTimeout(400); await shot("ch09-properties-tabs"); await page.getByText("Physics", { exact: true }).first().scrollIntoViewIfNeeded().catch(() => undefined); await page.waitForTimeout(400); await shot("ch21-physics"); await page.getByRole("button", { name: "文件", exact: true }).click(); await page.getByRole("menuitem", { name: "导入", exact: true }).click(); await page.waitForTimeout(300); await shot("ch25-import-menu"); await closeMenu(); await page.getByRole("button", { name: "文件", exact: true }).click(); await page.getByRole("menuitem", { name: "导出", exact: true }).click(); await page.waitForTimeout(300); await shot("ch25-export-menu"); await closeMenu(); await page.getByRole("button", { name: "编辑", exact: true }).click(); await page.getByRole("menuitem", { name: "偏好设置", exact: true }).click(); await page.getByTestId("preferences-window").waitFor({ timeout: 30_000 }); await shot("ch27-preferences"); await page.getByRole("button", { name: "键位", exact: true }).click(); await page.waitForTimeout(300); await shot("ch27-keymap"); await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click(); await page.getByRole("button", { name: "帮助", exact: true }).click(); await page.waitForTimeout(300); await shot("ch28-help"); await closeMenu(); } catch (error) { failures.push(error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error)); await page.screenshot({ path: path.join(OUT, "capture-failure.jpg"), type: "jpeg", quality: 82 }).catch(() => undefined); } finally { await browser.close().catch(() => undefined); const report = { schemaVersion: 1, status: failures.length ? "FAIL" : "PASS", generatedAt: new Date().toISOString(), gpuMode, images, failures }; fs.writeFileSync(REPORT, JSON.stringify(report, null, 2) + "\n", "utf8"); console.log(JSON.stringify({ status: report.status, images: images.length, failures: failures.length, report: REPORT }, null, 2)); if (failures.length) process.exitCode = 1; } } main().catch((error) => { console.error(error); process.exit(1); });