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>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
* 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";
|
||||
@@ -24,9 +25,20 @@ 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",
|
||||
@@ -42,6 +54,29 @@ const COURSES = {
|
||||
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";
|
||||
@@ -83,16 +118,145 @@ 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: 1, baseURL, startedAt: new Date().toISOString(), steps: [], errors: [] };
|
||||
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: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
|
||||
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
||||
const page = await context.newPage();
|
||||
@@ -105,8 +269,20 @@ async function main() {
|
||||
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: /从零制作/ }).waitFor({ timeout: 60_000 });
|
||||
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";
|
||||
@@ -115,7 +291,7 @@ async function main() {
|
||||
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.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 });
|
||||
|
||||
@@ -183,7 +359,7 @@ async function main() {
|
||||
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(() => undefined);
|
||||
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);
|
||||
}
|
||||
@@ -216,6 +392,12 @@ async function main() {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user