Files
workinf_Blender_Wasm/tools/web/manual/capture-manual.mjs
mes123456 b932db7642 feat(manual): add the illustrated Web Blender HTML user manual
A single-page 28-chapter manual generated from manual.json covers the
11 workspaces, all React editors, OPFS save/reopen, explicit local
downloads, import/export routes, preferences/keymap and the capability
matrix. It reuses verified course screenshots and adds 14 fresh
Chromium captures for shell, storage, area, camera, preferences, IO and
troubleshooting chapters.

Manual-driven fixes: Save As now states OPFS-only, GLTF import is
restored, quick toolbar honors its preference, startup workspace is
applied, Properties tabs jump to sections, Outliner fallback search
filters, and the editor-header ⋮ opens the area menu. Help and the
tutorial landing link the new manual; the PDF remains as an archive.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 11:58:40 +08:00

157 lines
8.1 KiB
JavaScript

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