Files
workinf_Blender_Wasm/tools/web/capture-parts-tutorial-screenshots.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

418 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Capture the illustrated parts tutorial from a headed Chromium browser.
*
* The script drives the real product entry (landing -> tutorial editor), runs
* every step's "演示本步" through the native Main, and writes screenshots plus a
* run report. By default it prefers the production build in web/dist; pass
* --dev to use the Vite dev server while iterating.
*
* Usage:
* node tools/web/capture-parts-tutorial-screenshots.mjs [--dev] [--port 5176]
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const webDir = path.join(repoRoot, "web");
const requireFromWeb = createRequire(path.join(webDir, "package.json"));
const { chromium } = requireFromWeb("playwright");
const args = process.argv.slice(2);
const useDev = args.includes("--dev");
const landingOnly = args.includes("--landing-only");
const rendersOnly = args.includes("--renders-only");
const portFlag = args.indexOf("--port");
const port = Number.parseInt(portFlag >= 0 ? args[portFlag + 1] : process.env.WEB_TEST_PORT ?? "5176", 10) || 5176;
const baseURL = `http://127.0.0.1:${port}`;
const gpuMode = 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",
];
const swiftshaderArgs = [
"--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis",
"--use-webgpu-adapter=swiftshader",
];
const COURSES = {
parts: {
route: "/?tutorial=parts",
assets: path.join(webDir, "app/public/tutorial/parts"),
report: path.join(repoRoot, "tests/golden/PARTS_TUTORIAL-001"),
frameAll: ["assembly-hierarchy", "materials", "save-export", "summary"],
reopen: "展开零件建模教程",
},
reducer: {
route: "/?tutorial=reducer",
assets: path.join(webDir, "app/public/tutorial/reducer"),
report: path.join(repoRoot, "tests/golden/REDUCER_TUTORIAL-001"),
frameAll: ["shaft-assemblies", "root-hierarchy", "collections", "materials-assign", "save-export", "summary"],
reopen: "展开二级减速器教程",
},
pbr: {
route: "/?ui=web&tutorial=pbr",
assets: path.join(webDir, "app/public/tutorial/pbr"),
report: path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001"),
frameAll: ["load-studio", "assign-emission", "render-setup", "download-save", "summary"],
reopen: "展开 PBR 渲染教程",
heading: /从零开始 PBR 渲染/,
renderMatrix: {
studio: path.join(webDir, "app/public/tutorial/pbr/studio.blend"),
rendersDir: path.join(webDir, "app/public/tutorial/pbr/renders"),
models: [
{ name: "PBR Suzanne", slug: "suzanne" },
{ name: "PBR Cube", slug: "cube" },
{ name: "PBR Torus", slug: "torus" },
],
materials: [
{ name: "PBR Metal Brushed", slug: "metal-brushed" },
{ name: "PBR Glass", slug: "glass" },
{ name: "PBR Wood", slug: "wood" },
{ name: "PBR Emission Cyan", slug: "emission-cyan" },
],
},
},
};
const courseFlag = args.indexOf("--course");
const courseId = courseFlag >= 0 ? args[courseFlag + 1] : "parts";
const course = COURSES[courseId];
if (!course) throw new Error(`Unknown tutorial course: ${courseId}`);
const outDir = path.join(course.assets, "steps");
const reportDir = course.report;
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(reportDir, { recursive: true });
function launchServer() {
if (useDev) {
const vite = path.join(webDir, "node_modules/vite/bin/vite.js");
return spawn(process.execPath, [vite, "--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"], env: process.env,
});
}
const index = path.join(webDir, "dist/index.html");
if (!fs.existsSync(index)) throw new Error("web/dist/index.html is missing; run `npm run build` or pass --dev");
return spawn(process.execPath, [path.join(repoRoot, "tools/web/deployment-http-server.mjs"), "dist"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) },
});
}
async function waitForServer(timeoutMs = 60_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${baseURL}/`, { signal: AbortSignal.timeout(2000) });
if (response.ok) return;
} catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(`server did not become ready at ${baseURL}`);
}
function chromePath() {
return process.env.CHROME_PATH || "/usr/bin/google-chrome";
}
function sha256File(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
async function openAssetLibrary(page) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }).catch(() => undefined);
if (await page.locator(".pbr-asset-library").isVisible().catch(() => false)) return;
await page.waitForTimeout(500);
}
throw new Error("PBR asset library did not become visible");
}
function loadDesktopRenderManifest() {
const manifestPath = path.join(course.assets, "renders", "manifest.json");
if (!fs.existsSync(manifestPath)) return null;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if ((manifest.renders?.length ?? 0) !== 12) return null;
return { renders: manifest.renders, renderer: manifest.renderer ?? "DESKTOP_BLENDER_EEVEE" };
}
async function captureRenderMatrix(context, report) {
const matrix = course.renderMatrix;
if (!matrix) return;
fs.mkdirSync(matrix.rendersDir, { recursive: true });
const manifest = [];
const page = await context.newPage();
page.on("pageerror", (error) => report.errors.push(`render pageerror: ${error.message}`));
try {
await page.goto(`${baseURL}/?ui=web`);
await page.locator(".blender-app").waitFor({ timeout: 60_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio);
await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 });
await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 });
await openAssetLibrary(page);
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 });
for (const [modelIndex, model] of matrix.models.entries()) {
const before = Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? 0);
await page.locator(".pbr-asset-card", { hasText: model.name }).first().dblclick();
await page.waitForFunction((expected) => {
const stats = document.querySelector('[data-testid="scene-stats"]');
const count = Number(/Objects (\d+)/.exec(stats?.textContent ?? "")?.[1] ?? 0);
return count >= expected;
}, before + 1, { timeout: 60_000 });
await page.waitForTimeout(1_000);
for (const material of matrix.materials) {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
const viewport = page.locator(".viewport-placeholder");
const viewportBox = await viewport.boundingBox().catch(() => null);
if (viewportBox) {
await viewport.click({ position: { x: viewportBox.width / 2, y: viewportBox.height / 2 } });
await page.waitForFunction((name) => document.querySelector('[data-testid="scene-stats"]')?.textContent?.includes(`Active ${name}`), model.name, { timeout: 15_000 }).catch(() => undefined);
}
await page.locator(".pbr-asset-card", { hasText: material.name }).first().dblclick();
await page.waitForFunction((expected) => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select?.selectedOptions?.[0]?.textContent?.trim().startsWith(expected);
}, material.name, { timeout: 60_000 });
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await page.locator('[aria-label="PBR 渲染分辨率"]').selectOption("1024");
await page.locator('[aria-label="PBR 渲染相机"]').selectOption("SCENE");
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
await page.waitForFunction(() => {
const image = document.querySelector(".pbr-render-result img");
return image instanceof HTMLImageElement && image.naturalWidth > 0;
}, undefined, { timeout: 120_000 });
const pngPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.png`);
const jpgPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.jpg`);
const downloadPromise = page.waitForEvent("download", { timeout: 60_000 });
await page.locator(".pbr-render-download").click();
const download = await downloadPromise;
await download.saveAs(pngPath);
const jpegDataUrl = await page.locator(".pbr-render-result img").evaluate(async (image) => {
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas.toDataURL("image/jpeg", 0.9);
});
fs.writeFileSync(jpgPath, Buffer.from(jpegDataUrl.split(",")[1], "base64"));
const entry = {
model: model.name,
modelSlug: model.slug,
material: material.name,
materialSlug: material.slug,
png: path.relative(repoRoot, pngPath).split(path.sep).join("/"),
jpg: path.relative(repoRoot, jpgPath).split(path.sep).join("/"),
width: 1024,
height: 1024,
bytes: fs.statSync(pngPath).size,
sha256: sha256File(pngPath),
};
manifest.push(entry);
report.renders.push(entry);
}
if (modelIndex < matrix.models.length - 1) {
// Reload instead of deleting: library appends can reset Main's active
// object, and a reload releases the native-files Web Lock cleanly.
await page.reload();
await page.locator(".blender-app").waitFor({ timeout: 60_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio);
await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 });
await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 });
await openAssetLibrary(page);
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 });
}
}
}
catch (error) {
report.errors.push(`render matrix: ${error instanceof Error ? error.message : String(error)}`);
await page.screenshot({ path: path.join(reportDir, "failure-render-matrix.png") }).catch(() => undefined);
}
finally {
await page.close().catch(() => undefined);
}
fs.writeFileSync(path.join(matrix.rendersDir, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, renders: manifest }, null, 2)}\n`);
}
async function main() {
const server = launchServer();
let browser;
const report = { schemaVersion: 2, course: courseId, baseURL, startedAt: new Date().toISOString(), steps: [], renders: [], errors: [] };
try {
await waitForServer();
browser = await chromium.launch({
headless: false,
executablePath: chromePath(),
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await context.newPage();
page.on("pageerror", (error) => report.errors.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() !== "error") return;
// The editor's existing inline-style CSP violation is unrelated to the
// tutorial and appears on every page load; keep it out of the step verdict.
if (message.text().includes("Content Security Policy directive") && message.text().includes("style")) return;
report.errors.push(`console: ${message.text()}`);
});
if (rendersOnly) {
const desktop = loadDesktopRenderManifest();
if (desktop) {
report.renders = desktop.renders;
report.renderer = desktop.renderer;
}
else await captureRenderMatrix(context, report);
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
return;
}
await page.goto(`${baseURL}${course.route}`);
await page.getByRole("heading", { name: course.heading ?? /从零制作/ }).waitFor({ timeout: 60_000 });
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.all(images.map((image) => image.complete ? Promise.resolve() : new Promise((resolve) => { image.addEventListener("load", resolve, { once: true }); image.addEventListener("error", resolve, { once: true }); })));
});
await page.screenshot({ path: path.join(course.assets, "landing.jpg"), fullPage: true, type: "jpeg", quality: 85 });
if (landingOnly) { report.status = "PASS"; return; }
await page.getByRole("button", { name: /开始学习|继续学习/ }).click();
await page.locator("[data-tutorial-step]").waitFor({ timeout: 90_000 });
await page.locator('.tutorial-loading[data-tutorial-ready="false"]').waitFor({ state: "detached", timeout: 120_000 });
let stepIndex = 0;
for (;;) {
const panel = page.locator("[data-tutorial-step]");
const stepId = await panel.getAttribute("data-tutorial-step");
stepIndex += 1;
const fileName = `${stepId}.jpg`;
const demo = page.getByRole("button", { name: "演示本步" });
let demoState = "none";
if (await demo.count()) {
try {
await demo.click({ timeout: 45_000 });
} catch (error) {
const state = await demo.getAttribute("data-demo-state").catch(() => null);
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
report.errors.push(`${stepId}: demo button not actionable (${state ?? "unknown"}); engine=${engineStatus ?? "unknown"}`);
throw error;
}
demoState = "clicked";
await page.waitForFunction(() => {
if (document.querySelector(".tutorial-error")) return "error";
const rows = [...document.querySelectorAll("[data-check-id]")];
const demo = document.querySelector(".tutorial-demo");
const running = demo?.getAttribute("data-demo-state") === "running";
return !running && rows.length > 0 && rows.every((row) => row.getAttribute("data-ok") === "true") ? "ok" : false;
}, undefined, { timeout: 180_000 }).then((outcome) => {
if (outcome === "error") throw new Error("demo reported an error");
}).catch(async (error) => {
const overlay = (await page.locator(".tutorial-error").allTextContents()).join(" ") || "checks did not pass";
const checks = await page.locator("[data-check-id]").evaluateAll((rows) => rows.map((row) => ({
id: row.getAttribute("data-check-id"),
ok: row.getAttribute("data-ok"),
text: (row.textContent ?? "").trim().slice(0, 200),
})));
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
const actions = await page.evaluate(() => {
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
save: main?.getAttribute("data-user-action-save-status"), saveAs: main?.getAttribute("data-user-action-save-as-status"),
saveAsError: main?.getAttribute("data-user-action-save-as-error"),
export: main?.getAttribute("data-user-action-export-status"), exportError: main?.getAttribute("data-user-action-export-error"),
demo: demo?.getAttribute("data-demo-state"), demoText: demo?.textContent?.trim(),
};
});
await page.screenshot({ path: path.join(reportDir, `failure-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`${stepId}: ${overlay} :: ${JSON.stringify({ checks, engineStatus, actions })}`);
demoState = "failed";
throw error;
});
} else {
// Guide/summary cards only wait for a ready engine.
await page.waitForFunction(() => !document.querySelector('.tutorial-loading[data-tutorial-ready="false"]'), undefined, { timeout: 120_000 });
}
await page.waitForTimeout(500);
// Capture the clean viewport reference first so the full screenshot below
// already shows the step's reference image instead of a pending request.
const collapse = page.getByRole("button", { name: "收起教程侧栏" });
if (await collapse.count()) {
await collapse.click();
// Frame the step's subject. Steps whose active object is the assembly
// Empty use the whole scene instead of framing an empty point.
const frameAll = course.frameAll.includes(stepId);
await page.getByRole("button", { name: frameAll ? "显示全部" : "框显所选" }).click({ timeout: 5000 }).catch(() => undefined);
await page.waitForTimeout(400);
await page.locator(".viewport-placeholder").screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), type: "jpeg", quality: 85 }).catch(async () => { await page.screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), fullPage: false, type: "jpeg", quality: 85 }).catch(() => undefined); });
await page.getByRole("button", { name: course.reopen }).click();
await page.waitForTimeout(600);
}
await page.screenshot({ path: path.join(outDir, fileName), fullPage: false, type: "jpeg", quality: 85 });
report.steps.push({ index: stepIndex, stepId, fileName, demo: demoState });
const next = page.getByRole("button", { name: /下一步|下一节/ });
if (!(await next.count())) break;
try {
await next.click({ timeout: 60_000 });
} catch (error) {
const state = await page.evaluate(() => {
const panel = document.querySelector("[data-tutorial-step]");
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
step: panel?.getAttribute("data-tutorial-step"),
project: panel?.getAttribute("data-tutorial-project"),
ready: panel?.getAttribute("data-tutorial-ready"),
demo: demo?.getAttribute("data-demo-state"),
save: main?.getAttribute("data-user-action-save-status"),
checks: [...document.querySelectorAll("[data-check-id]")].map((row) => ({ id: row.getAttribute("data-check-id"), ok: row.getAttribute("data-ok") })),
error: document.querySelector(".tutorial-error")?.textContent,
};
}).catch(() => null);
await page.screenshot({ path: path.join(reportDir, `failure-next-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`next-click ${stepId}: ${JSON.stringify(state)}`);
throw error;
}
await page.waitForTimeout(300);
}
await page.screenshot({ path: path.join(outDir, "00-assembly.jpg"), fullPage: false, type: "jpeg", quality: 85 });
const desktopRenders = loadDesktopRenderManifest();
if (desktopRenders) {
report.renders = desktopRenders.renders;
report.renderer = desktopRenders.renderer;
}
else await captureRenderMatrix(context, report);
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
} catch (error) {
report.status = "FAILED";
report.errors.push(error instanceof Error ? error.stack ?? error.message : String(error));
} finally {
await browser?.close().catch(() => undefined);
server.kill("SIGTERM");
fs.writeFileSync(path.join(reportDir, `headed-screenshots-${Date.now()}.json`), JSON.stringify(report, null, 2));
const latest = path.join(reportDir, "headed-screenshots-latest.json");
fs.writeFileSync(latest, JSON.stringify(report, null, 2));
console.log(JSON.stringify({ status: report.status, steps: report.steps.length, errors: report.errors.slice(0, 4) }, null, 2));
process.exitCode = report.status === "PASS" ? 0 : 1;
}
}
await main();