- 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>
313 lines
18 KiB
TypeScript
313 lines
18 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
|
|
const MONKEY = path.resolve(HERE, "../../../tests/files/web/pbr-render-lesson/monkey-head.glb");
|
|
const RE_MATERIALS = [
|
|
"Black_Anti_Slip_grip",
|
|
"Blackish_Brick_Wall_016",
|
|
"black_randome_tile",
|
|
"black_shinning_tile",
|
|
"black_tiles_wall",
|
|
"black_worn_wall_brick",
|
|
];
|
|
|
|
test.use({ viewport: { width: 1600, height: 1000 } });
|
|
|
|
const objectCount = async (page: import("@playwright/test").Page): Promise<number> => {
|
|
const text = await page.getByTestId("scene-stats").innerText();
|
|
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
|
|
};
|
|
|
|
const resultMean = async (page: import("@playwright/test").Page): Promise<number> => page.evaluate(() => {
|
|
const image = document.querySelector(".pbr-render-result img");
|
|
if (!(image instanceof HTMLImageElement) || !image.naturalWidth) return 0;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context) return 0;
|
|
context.drawImage(image, 0, 0);
|
|
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let sum = 0;
|
|
for (let index = 0; index < data.length; index += 4) sum += (data[index] + data[index + 1] + data[index + 2]) / 3;
|
|
return sum / (data.length / 4);
|
|
});
|
|
|
|
async function openStudio(page: import("@playwright/test").Page): Promise<void> {
|
|
await page.goto("/?ui=web");
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
|
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 });
|
|
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 });
|
|
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(6);
|
|
const webTools = page.locator('[data-testid="native-web-tools-toggle"]');
|
|
if (await webTools.count()) await webTools.click();
|
|
}
|
|
|
|
async function selectSuzanne(page: import("@playwright/test").Page): Promise<void> {
|
|
const expand = page.locator('.outliner-tree .tree-expand[aria-label^="展开"]').first();
|
|
if (await expand.count()) await expand.click();
|
|
const row = page.getByRole("treeitem").filter({ hasText: /Suzanne/ }).first();
|
|
await expect(row).toBeVisible({ timeout: 60_000 });
|
|
await row.click();
|
|
}
|
|
|
|
test("RE 5000PBR: import monkey GLB, assign six materials, render and save", async ({ page }, testInfo) => {
|
|
test.setTimeout(600_000);
|
|
await openStudio(page);
|
|
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY);
|
|
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
|
await expect(page.getByText(/Engine: GLB imported/).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined);
|
|
|
|
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
|
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 });
|
|
await expect(page.locator(".pbr-asset-card")).toHaveCount(20, { timeout: 90_000 });
|
|
await expect(page.locator('[data-library="library:re-pbr-v1"]')).toHaveCount(6, { timeout: 90_000 });
|
|
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"] .pbr-asset-thumb img').evaluateAll((images) =>
|
|
images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 60_000 }).toBe(6);
|
|
|
|
for (const name of RE_MATERIALS) {
|
|
const card = page.locator('[data-library="library:re-pbr-v1"]', { hasText: name }).first();
|
|
await expect(card).toBeVisible({ timeout: 30_000 });
|
|
await card.dblclick();
|
|
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 })
|
|
.toBe(`material:${name}`);
|
|
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "true", { timeout: 30_000 }).catch(() => undefined);
|
|
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 });
|
|
}
|
|
await expect.poll(async () => page.locator(".material-preview-status").innerText({ timeout: 60_000 }), { timeout: 90_000 })
|
|
.toContain("COMPILED");
|
|
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
const lessonPanel = page.locator(".pbr-lesson-panel");
|
|
await expect(lessonPanel).toBeVisible({ timeout: 60_000 });
|
|
await expect(lessonPanel).toContainText("20 个资产");
|
|
await expect(lessonPanel).toContainText("6 个材质");
|
|
await expect(lessonPanel.getByRole("link", { name: /打开图文/ }))
|
|
.toHaveAttribute("href", "/tutorial/pbr/render-lesson.html");
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1);
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").first().evaluate((image) =>
|
|
(image as HTMLImageElement).naturalWidth), { timeout: 60_000 }).toBe(1024);
|
|
const panel = page.locator(".pbr-render-panel");
|
|
await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 60_000 }).toMatch(/^ready:/);
|
|
await expect.poll(async () => Number(await panel.getAttribute("data-texture-assets") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(0);
|
|
const sourceTriangles = Number(await panel.getAttribute("data-displacement-source-triangles") ?? "0");
|
|
const renderTriangles = Number(await panel.getAttribute("data-displacement-render-triangles") ?? "0");
|
|
expect(sourceTriangles).toBeGreaterThan(0);
|
|
expect(renderTriangles).toBeGreaterThan(sourceTriangles);
|
|
expect(Number(await panel.getAttribute("data-displacement-level") ?? "0")).toBeGreaterThan(0);
|
|
expect(Number(await panel.getAttribute("data-displacement-multiplier") ?? "0")).toBe(1);
|
|
const mean1x = await resultMean(page);
|
|
|
|
const png = page.waitForEvent("download");
|
|
await page.locator(".pbr-render-download").click();
|
|
const pngDownload = await png;
|
|
expect(pngDownload.suggestedFilename()).toMatch(/\.png$/);
|
|
const pngPath = testInfo.outputPath("re-monkey-1024.png");
|
|
await pngDownload.saveAs(pngPath);
|
|
expect(fs.statSync(pngPath).size).toBeGreaterThan(1024);
|
|
|
|
const blend = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "保存项目", exact: true }).click();
|
|
const blendDownload = await blend;
|
|
const blendPath = testInfo.outputPath("re-monkey-saved.blend");
|
|
await blendDownload.saveAs(blendPath);
|
|
expect(fs.statSync(blendPath).size).toBeGreaterThan(12);
|
|
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(blendPath);
|
|
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 });
|
|
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 });
|
|
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
|
const options = await page.locator('select[aria-label^="材质槽"]').first().locator("option").allTextContents();
|
|
expect(options.join(" ")).toContain(RE_MATERIALS[RE_MATERIALS.length - 1]);
|
|
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1);
|
|
const reopenPanel = page.locator(".pbr-render-panel");
|
|
await expect.poll(async () => reopenPanel.getAttribute("data-texture-status"), { timeout: 120_000 })
|
|
.toMatch(/^ready:([1-9]\d*):0:/);
|
|
const [expectedText, statusText] = await Promise.all([
|
|
reopenPanel.getAttribute("data-texture-expected"),
|
|
reopenPanel.getAttribute("data-texture-status"),
|
|
]);
|
|
expect(Number(statusText?.split(":")[1] ?? "0")).toBe(Number(expectedText ?? "0"));
|
|
expect(Number(await reopenPanel.getAttribute("data-texture-revision"))).toBeGreaterThan(0);
|
|
|
|
const scenePosition = await reopenPanel.getAttribute("data-render-camera-position");
|
|
expect(scenePosition).toBeTruthy();
|
|
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT");
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => reopenPanel.getAttribute("data-render-camera-position"), { timeout: 120_000 })
|
|
.not.toBe(scenePosition);
|
|
await expect(reopenPanel).toHaveAttribute("data-view-preset", "COMPARISON");
|
|
expect(await reopenPanel.getAttribute("data-render-camera-position")).not.toBe(scenePosition);
|
|
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
|
const before100 = await page.locator(".pbr-render-result img").getAttribute("src");
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 })
|
|
.not.toBe(before100);
|
|
await expect(reopenPanel).toHaveAttribute("data-displacement-multiplier", "100");
|
|
const mean100x = await resultMean(page);
|
|
expect(Math.abs(mean100x - mean1x)).toBeGreaterThan(0.01);
|
|
const displacementDownload = page.waitForEvent("download");
|
|
await page.locator(".pbr-render-download").click();
|
|
const displacementPng = testInfo.outputPath("re-monkey-displacement-100x.png");
|
|
await (await displacementDownload).saveAs(displacementPng);
|
|
expect(fs.statSync(displacementPng).size).toBeGreaterThan(1024);
|
|
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("512");
|
|
for (const [mode, name] of [["BASE_COLOR", "base-color"], ["NORMAL", "normal"], ["ROUGHNESS", "roughness"]] as const) {
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode);
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
|
const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 })
|
|
.not.toBe(previous);
|
|
await expect(reopenPanel).toHaveAttribute("data-display-mode", mode);
|
|
expect(Number(await reopenPanel.getAttribute("data-diagnostic-meshes") ?? "0")).toBeGreaterThan(0);
|
|
const diagnosticDownload = page.waitForEvent("download");
|
|
await page.locator(".pbr-render-download").click();
|
|
const diagnosticPath = testInfo.outputPath(`diagnostic-${name}-512.png`);
|
|
await (await diagnosticDownload).saveAs(diagnosticPath);
|
|
expect(fs.statSync(diagnosticPath).size).toBeGreaterThan(1024);
|
|
}
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
|
const exposurePrevious = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposurePrevious);
|
|
const exposure0 = await resultMean(page);
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2");
|
|
const exposureNext = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposureNext);
|
|
const exposure2 = await resultMean(page);
|
|
expect(Math.abs(exposure2 - exposure0)).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("RE 5000PBR: truncated GLB is rejected without scene mutation", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
await openStudio(page);
|
|
const before = await objectCount(page);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles({
|
|
name: "truncated.glb",
|
|
mimeType: "model/gltf-binary",
|
|
buffer: Buffer.from("glTF\x02\x00\x00\x00\x10\x00\x00\x00", "binary"),
|
|
});
|
|
await expect.poll(() => objectCount(page), { timeout: 60_000 }).toBe(before);
|
|
await expect(page.getByText(/GLB|glTF|导入失败|import/i).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined);
|
|
});
|
|
|
|
test("RE 5000PBR native reader exposes HEIGHT displacement binding", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async () => {
|
|
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
|
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
|
try {
|
|
await client.init();
|
|
const buffer = await (await fetch("/assets/re-pbr/re-pbr-library.blend")).arrayBuffer();
|
|
const opened = await client.openBlend(buffer);
|
|
const material = opened.snapshot.materials.find((candidate) => candidate.name === "Black_Anti_Slip_grip");
|
|
return {
|
|
binding: material?.textureBindings?.find((candidate) => candidate.usage === "HEIGHT") ?? null,
|
|
displacementImageId: material?.displacementImageId ?? null,
|
|
displacementScale: material?.displacementScale ?? null,
|
|
displacementMidlevel: material?.displacementMidlevel ?? null,
|
|
textureBindings: material?.textureBindings ?? null,
|
|
nodeTypes: material?.nodes?.map((node) => node.type) ?? null,
|
|
links: material?.links ?? null,
|
|
materials: opened.snapshot.materials.map((candidate) => candidate.name),
|
|
};
|
|
}
|
|
finally {
|
|
client.terminate();
|
|
}
|
|
});
|
|
expect(result.binding).toMatchObject({ usage: "HEIGHT", midlevel: 0.5 });
|
|
expect(result.binding.scale).toBeCloseTo(0.001, 6);
|
|
expect(result.displacementImageId).toContain("Height");
|
|
expect(result.displacementScale).toBeCloseTo(0.001, 6);
|
|
expect(result.displacementMidlevel).toBeCloseTo(0.5, 6);
|
|
|
|
});
|
|
|
|
test("RE 5000PBR: optional HQ 2048 library loads on demand and renders", async ({ page }, testInfo) => {
|
|
test.setTimeout(600_000);
|
|
await openStudio(page);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY);
|
|
await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7);
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载/ });
|
|
await expect(hqButton).toBeVisible({ timeout: 60_000 });
|
|
if (await hqButton.getAttribute("disabled") === null) {
|
|
await hqButton.click();
|
|
}
|
|
await expect(page.getByRole("button", { name: "高清贴图已加载" })).toBeVisible({ timeout: 180_000 });
|
|
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
|
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 });
|
|
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-hq-v1");
|
|
await expect(page.locator('[data-library="library:re-pbr-hq-v1"]')).toHaveCount(6, { timeout: 60_000 });
|
|
const card = page.locator('[data-library="library:re-pbr-hq-v1"]', { hasText: "black_worn_wall_brick_HQ" }).first();
|
|
await card.dblclick();
|
|
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 })
|
|
.toBe("material:black_worn_wall_brick_HQ");
|
|
await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 });
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
|
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
|
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 180_000 }).toBe(1);
|
|
const panel = page.locator(".pbr-render-panel");
|
|
await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 180_000 }).toMatch(/^ready:([1-9]\d*):0:/);
|
|
expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeGreaterThan(0);
|
|
expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeLessThan(512 * 1024 * 1024);
|
|
const download = page.waitForEvent("download");
|
|
await page.locator(".pbr-render-download").click();
|
|
const png = testInfo.outputPath("re-pbr-hq-2048.png");
|
|
await (await download).saveAs(png);
|
|
expect(fs.statSync(png).size).toBeGreaterThan(1024);
|
|
});
|
|
|
|
|
|
test("RE 5000PBR HQ native images keep 2048 dimensions", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await page.goto("/");
|
|
const images = await page.evaluate(async () => {
|
|
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
|
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
|
try {
|
|
await client.init();
|
|
const buffer = await (await fetch("/assets/re-pbr-hq/re-pbr-library.blend")).arrayBuffer();
|
|
const opened = await client.openBlend(buffer);
|
|
return opened.snapshot.images.map((image) => ({
|
|
name: image.name,
|
|
width: image.width,
|
|
height: image.height,
|
|
tiles: image.tiles?.map((tile) => ({ width: tile.width, height: tile.height })) ?? null,
|
|
}));
|
|
}
|
|
finally { client.terminate(); }
|
|
});
|
|
const heightImages = images.filter((image) => /Height\.jpg$/.test(image.name));
|
|
expect(heightImages.length).toBeGreaterThanOrEqual(1);
|
|
for (const image of heightImages) {
|
|
expect(image.width).toBe(2048);
|
|
expect(image.height).toBe(2048);
|
|
}
|
|
});
|