Generate a static beginner course from course data with real Chromium screenshots for every module, and record a narrated walkthrough of UV, Texture Paint, Compositor, Geometry Nodes, Spreadsheet and OPFS reopen. The render lesson video covers M08/M09; the course page is linked from the tutorial landing. Co-Authored-By: Claude Code <noreply@anthropic.com>
381 lines
17 KiB
JavaScript
381 lines
17 KiB
JavaScript
#!/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); });
|