- 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>
471 lines
24 KiB
JavaScript
471 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Headed Chromium recording for the RE 5000PBR lesson video.
|
|
*
|
|
* Usage:
|
|
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
|
|
* node tools/web/record-re-pbr-lesson-video.mjs --dev --port 5184 --gpu hardware \
|
|
* --out tests/golden/RE-PBR-VIDEO-001
|
|
*
|
|
* Prepare narration first:
|
|
* python3 -I tools/web/generate-re-pbr-narration.py --mode prepare
|
|
*/
|
|
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
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 LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
|
|
const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend");
|
|
const GLB = path.join(REPO, "re/猴头.glb");
|
|
const MATERIALS = [
|
|
"Black_Anti_Slip_grip",
|
|
"Blackish_Brick_Wall_016",
|
|
"black_randome_tile",
|
|
"black_shinning_tile",
|
|
"black_tiles_wall",
|
|
"black_worn_wall_brick",
|
|
];
|
|
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 useDev = args.includes("--dev");
|
|
const smoke = args.includes("--smoke");
|
|
const port = Number.parseInt(flag("port", "5184"), 10) || 5184;
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const OUT = path.resolve(flag("out", process.env.RE_PBR_VIDEO_OUT ?? path.join(REPO, "tests/golden/RE-PBR-VIDEO-001")));
|
|
const NARRATION = path.join(OUT, "narration.json");
|
|
const gpuMode = flag("gpu", 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",
|
|
"--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows",
|
|
"--disable-renderer-backgrounding", "--hide-scrollbars",
|
|
];
|
|
const swiftshaderArgs = [
|
|
"--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
|
|
"--enable-unsafe-webgpu", "--disable-background-timer-throttling",
|
|
"--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--hide-scrollbars",
|
|
];
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
const DIAG = path.join(OUT, "diagnostics");
|
|
fs.mkdirSync(DIAG, { recursive: true });
|
|
fs.mkdirSync(path.join(LESSON, "video"), { recursive: true });
|
|
fs.mkdirSync(path.join(LESSON, "video-steps"), { recursive: true });
|
|
// Preserve prior raw Playwright recordings for diagnosis; only clear the mixed target.
|
|
fs.rmSync(path.join(OUT, "screencast.webm"), { force: true });
|
|
|
|
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
const fileSha = (file) => sha256(fs.readFileSync(file));
|
|
const narration = JSON.parse(fs.readFileSync(NARRATION, "utf8"));
|
|
const durationOf = (id) => narration.chapters.find((chapter) => chapter.id === id)?.durationMs ?? 6000;
|
|
|
|
let serverExit = null;
|
|
async function waitForServer(url, timeoutMs = 60_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (serverExit) throw new Error(`server exited before becoming ready: ${JSON.stringify(serverExit)}`);
|
|
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}`);
|
|
}
|
|
function track(child) {
|
|
child.on("exit", (code, signal) => { serverExit = { code, signal }; });
|
|
child.stdout.on("data", () => undefined); child.stderr.on("data", () => undefined);
|
|
return child;
|
|
}
|
|
function startServer() {
|
|
if (useDev) {
|
|
return track(spawn(process.execPath, [path.join(WEB, "node_modules/vite/bin/vite.js"),
|
|
"--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
|
|
{ cwd: WEB, stdio: ["ignore", "pipe", "pipe"] }));
|
|
}
|
|
return track(spawn(process.execPath, [path.join(REPO, "tools/web/deployment-http-server.mjs"), "dist"],
|
|
{ cwd: WEB, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) } }));
|
|
}
|
|
|
|
async function installOverlay(page) {
|
|
await page.evaluate(() => {
|
|
if (!document.querySelector('link[data-lesson-video-overlay]')) {
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = "/tutorial/pbr/lesson/video-overlay.css";
|
|
link.dataset.lessonVideoOverlay = "";
|
|
document.head.append(link);
|
|
}
|
|
if (document.getElementById("__lesson_video_overlay")) return;
|
|
const root = document.createElement("div");
|
|
root.id = "__lesson_video_overlay";
|
|
const cursor = document.createElement("div");
|
|
cursor.id = "__lesson_video_cursor";
|
|
const caption = document.createElement("div");
|
|
caption.id = "__lesson_video_caption";
|
|
caption.textContent = "Web Blender · RE 5000PBR 渲染课";
|
|
root.append(cursor, caption);
|
|
document.body.append(root);
|
|
window.__lessonSetCaption = (text) => { caption.textContent = text; };
|
|
window.addEventListener("mousemove", (event) => {
|
|
cursor.getAnimations().forEach((animation) => animation.cancel());
|
|
cursor.animate([{ transform: `translate(${event.clientX}px, ${event.clientY}px)` }], { duration: 90, fill: "forwards", easing: "linear" });
|
|
}, { passive: true });
|
|
window.addEventListener("mousedown", (event) => {
|
|
const ripple = document.createElement("div");
|
|
ripple.className = "__lesson_video_ripple";
|
|
root.append(ripple);
|
|
ripple.animate([
|
|
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(1)`, opacity: 0.95 },
|
|
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(4.2)`, opacity: 0 },
|
|
], { duration: 520, easing: "ease-out" }).finished.then(() => ripple.remove());
|
|
}, { passive: true });
|
|
});
|
|
}
|
|
|
|
async function hideOverlay(page, hidden) {
|
|
await page.evaluate((value) => {
|
|
const root = document.getElementById("__lesson_video_overlay");
|
|
if (root) root.hidden = value;
|
|
}, hidden);
|
|
}
|
|
|
|
async function moveClick(page, locator) {
|
|
await locator.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
const box = await locator.boundingBox().catch(() => null);
|
|
if (!box) { await locator.click({ force: true }); return; }
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 14 });
|
|
await page.waitForTimeout(120);
|
|
await locator.click({ force: true });
|
|
}
|
|
|
|
async function main() {
|
|
const server = startServer();
|
|
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
|
|
const errors = [];
|
|
const timeline = [];
|
|
const recordingStart = Date.now();
|
|
const consoleLog = path.join(DIAG, `console-${recordingStart}.log`);
|
|
let browser;
|
|
let context;
|
|
let page;
|
|
let video;
|
|
let probe = null;
|
|
let fatalError = null;
|
|
try {
|
|
await waitForServer(`${baseUrl}/`);
|
|
browser = await chromium.launch({
|
|
headless: false, executablePath: chromePath,
|
|
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
|
|
});
|
|
context = await browser.newContext({
|
|
viewport: { width: 1600, height: 900 },
|
|
acceptDownloads: true,
|
|
recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
|
|
});
|
|
page = await context.newPage();
|
|
video = page.video();
|
|
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
|
page.on("crash", () => errors.push("page crashed"));
|
|
page.on("console", (message) => {
|
|
const text = message.text();
|
|
fs.appendFileSync(consoleLog, `${new Date().toISOString()} ${message.type()} ${text}\n`);
|
|
if (message.type() !== "error") return;
|
|
const location = message.location();
|
|
if (/favicon/i.test(location.url) || /Failed to load resource.*404/i.test(text)) return;
|
|
if (!/Content Security Policy|style-src/i.test(text)) errors.push(`console: ${text}`);
|
|
});
|
|
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
|
|
await page.locator('main.blender-app').waitFor({ timeout: 90_000 });
|
|
await page.waitForTimeout(1500);
|
|
await installOverlay(page);
|
|
await page.waitForTimeout(500);
|
|
|
|
probe = await page.evaluate(async () => {
|
|
for (const canvas of document.querySelectorAll("canvas")) {
|
|
const gl = canvas.getContext("webgl2");
|
|
if (!gl) continue;
|
|
const debug = gl.getExtension("WEBGL_debug_renderer_info");
|
|
return { vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED", version: gl.getParameter(gl.VERSION) };
|
|
}
|
|
return null;
|
|
});
|
|
if (!probe) throw new Error("no WebGL2 canvas for video probe");
|
|
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) throw new Error(`hardware GPU unavailable: ${probe.renderer}`);
|
|
|
|
const objectCount = async () => Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? "0");
|
|
const caption = async (text) => { await page.evaluate((value) => window.__lessonSetCaption?.(value), text); };
|
|
const shot = async (id) => {
|
|
await hideOverlay(page, true);
|
|
const file = path.join(LESSON, "video-steps", `${id}.jpg`);
|
|
await page.screenshot({ path: file, type: "jpeg", quality: 84 });
|
|
await hideOverlay(page, false);
|
|
return path.relative(REPO, file);
|
|
};
|
|
const waitRender = async (previousSrc, resolution) => {
|
|
await page.waitForFunction(({ previous, expected }) => {
|
|
const image = document.querySelector(".pbr-render-result img");
|
|
const button = [...document.querySelectorAll(".pbr-render-panel button")].find((candidate) => /渲染 PBR|渲染中/.test(candidate.textContent ?? ""));
|
|
return image instanceof HTMLImageElement && image.naturalWidth === expected && image.getAttribute("src") !== previous && button?.textContent?.includes("渲染 PBR");
|
|
}, { previous: previousSrc, expected: resolution }, { timeout: 180_000 });
|
|
};
|
|
const render = async (resolution = 1024, camera = "SCENE") => {
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
|
|
const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
|
await moveClick(page, page.getByRole("button", { name: "渲染 PBR", exact: true }));
|
|
await waitRender(previous, resolution);
|
|
return page.locator(".pbr-render-panel").getAttribute("data-texture-status");
|
|
};
|
|
const dismissDiscardDialog = async () => {
|
|
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
|
|
await dialog.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined);
|
|
if (await dialog.count()) {
|
|
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
|
|
if (await discard.count()) await discard.click().catch(() => undefined);
|
|
}
|
|
};
|
|
const waitStudioReady = async () => {
|
|
await page.locator('main.blender-app[data-project-id]').waitFor({ timeout: 90_000 });
|
|
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
|
|
await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
};
|
|
const ensureRendering = async () => {
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }).catch(() => undefined);
|
|
await page.locator(".pbr-lesson-panel").waitFor({ timeout: 15_000 }).catch(() => undefined);
|
|
};
|
|
const openStudio = async () => {
|
|
await ensureRendering();
|
|
try {
|
|
const button = page.getByRole("button", { name: "载入练习摄影棚", exact: true });
|
|
const enabled = await button.isEnabled({ timeout: 5000 }).catch(() => false);
|
|
if (!enabled) throw new Error("studio button not enabled");
|
|
await moveClick(page, button);
|
|
await dismissDiscardDialog();
|
|
await Promise.race([
|
|
waitStudioReady(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("studio UI path timed out")), 45_000)),
|
|
]);
|
|
return;
|
|
}
|
|
catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
errors.push(`open-studio UI path: ${message}`);
|
|
console.error(`open-studio UI path failed: ${message}; falling back to file input`);
|
|
await page.screenshot({ path: path.join(DIAG, "open-studio-ui-failed.png") }).catch(() => undefined);
|
|
}
|
|
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
|
|
await page.locator("main.blender-app").waitFor({ timeout: 90_000 });
|
|
await installOverlay(page);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
|
await dismissDiscardDialog();
|
|
await waitStudioReady();
|
|
await ensureRendering();
|
|
};
|
|
const importGlb = async () => {
|
|
const chooserPromise = page.waitForEvent("filechooser", { timeout: 30_000 });
|
|
await moveClick(page, page.getByRole("button", { name: "导入猴头 GLB", exact: true }));
|
|
const chooser = await chooserPromise;
|
|
await chooser.setFiles(GLB);
|
|
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
};
|
|
const chapter = async (id, title, action) => {
|
|
await caption(title);
|
|
const startMs = Date.now() - recordingStart;
|
|
try {
|
|
await action();
|
|
}
|
|
catch (error) {
|
|
const screenshot = await shot(id).catch(() => "");
|
|
await page.screenshot({ path: path.join(DIAG, `failure-${id}.png`) }).catch(() => undefined);
|
|
const state = await page.evaluate(() => ({
|
|
projectId: document.querySelector("main.blender-app")?.getAttribute("data-project-id"),
|
|
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
|
|
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
|
|
workspace: document.querySelector(".workspace-tab.active")?.textContent,
|
|
lessonPanels: document.querySelectorAll(".pbr-lesson-panel").length,
|
|
renderImage: document.querySelectorAll(".pbr-render-result img").length,
|
|
textureStatus: document.querySelector(".pbr-render-panel")?.getAttribute("data-texture-status"),
|
|
})).catch(() => null);
|
|
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot, error: String(error), state });
|
|
errors.push(`chapter ${id}: ${error instanceof Error ? error.stack ?? error.message : String(error)} :: ${JSON.stringify(state)}`);
|
|
throw error;
|
|
}
|
|
const waitMs = Math.max(0, startMs + durationOf(id) + 15_800 - (Date.now() - recordingStart));
|
|
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
|
const screenshot = await shot(id);
|
|
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot });
|
|
console.log(`chapter ${id} ${startMs}ms-${Date.now() - recordingStart}ms`);
|
|
};
|
|
|
|
await chapter("intro", "开场:会得到什么", async () => {
|
|
await page.waitForTimeout(1200);
|
|
});
|
|
await chapter("open-studio", "载入摄影棚", async () => {
|
|
await openStudio();
|
|
});
|
|
await chapter("import-glb", "导入猴头 GLB", async () => {
|
|
await importGlb();
|
|
});
|
|
if (!smoke) {
|
|
await chapter("asset-library", "认识 Shading 与 RE 资产库", async () => {
|
|
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
|
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
|
|
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 90_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, undefined, { timeout: 60_000 });
|
|
const card = page.locator('[data-library="library:re-pbr-v1"]').first();
|
|
await card.scrollIntoViewIfNeeded();
|
|
await moveClick(page, card);
|
|
});
|
|
await chapter("assign-materials", "分配六种材质", async () => {
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
await page.locator(".pbr-lesson-picker").waitFor({ timeout: 60_000 });
|
|
for (const name of MATERIALS) {
|
|
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: name }).first();
|
|
await card.scrollIntoViewIfNeeded();
|
|
await moveClick(page, card);
|
|
await card.dblclick();
|
|
await page.waitForFunction((expected) => {
|
|
const select = document.querySelector('select[aria-label^="材质槽"]');
|
|
return select instanceof HTMLSelectElement && select.value.startsWith(`material:${expected}`);
|
|
}, name, { timeout: 180_000 });
|
|
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
|
|
}
|
|
});
|
|
await chapter("render-settings", "设置渲染", async () => {
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
|
|
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
|
|
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
|
|
});
|
|
await chapter("render-download", "渲染与下载 PNG", async () => {
|
|
const status = await render(1024, "SCENE");
|
|
if (!/^ready:/.test(String(status))) throw new Error(`textures not ready: ${status}`);
|
|
const download = page.waitForEvent("download", { timeout: 60_000 });
|
|
await moveClick(page, page.locator(".pbr-render-download"));
|
|
const file = await download;
|
|
await file.saveAs(path.join(OUT, "video-render-1024.png"));
|
|
});
|
|
await chapter("save-reopen", "保存与重开", async () => {
|
|
const saving = page.waitForEvent("download", { timeout: 60_000 });
|
|
await moveClick(page, page.getByRole("button", { name: "保存项目", exact: true }));
|
|
const saved = await saving;
|
|
const savedPath = path.join(OUT, "lesson-video-saved.blend");
|
|
await saved.saveAs(savedPath);
|
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(savedPath);
|
|
await page.waitForTimeout(500);
|
|
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
|
|
if (await dialog.count()) {
|
|
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
|
|
if (await discard.count()) await discard.click();
|
|
}
|
|
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
|
|
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
|
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
|
await render(1024, "SCENE");
|
|
});
|
|
await chapter("diagnostics", "诊断视图与教学曝光", async () => {
|
|
for (const mode of ["BASE_COLOR", "NORMAL", "ROUGHNESS"]) {
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode);
|
|
await render(512, "SCENE");
|
|
await page.waitForTimeout(500);
|
|
}
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2");
|
|
await render(512, "SCENE");
|
|
});
|
|
await chapter("displacement", "位移与细分", async () => {
|
|
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
|
|
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
|
|
await render(1024, "SCENE");
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100");
|
|
await render(1024, "SCENE");
|
|
});
|
|
await chapter("camera", "对比视角", async () => {
|
|
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
|
|
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON");
|
|
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT");
|
|
await render(1024, "VIEWPORT");
|
|
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
|
|
});
|
|
await chapter("hq-2048", "高清 2048 贴图", async () => {
|
|
const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载|加载中/ });
|
|
await hqButton.scrollIntoViewIfNeeded();
|
|
if ((await hqButton.getAttribute("disabled")) === null) await moveClick(page, hqButton);
|
|
await page.getByRole("button", { name: "高清贴图已加载" }).waitFor({ timeout: 180_000 });
|
|
const hqSource = page.getByRole("button", { name: "高清 2048", exact: true });
|
|
await moveClick(page, hqSource);
|
|
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: "black_worn_wall_brick_HQ" }).first();
|
|
await card.scrollIntoViewIfNeeded();
|
|
await moveClick(page, card);
|
|
await card.dblclick();
|
|
await page.waitForFunction(() => {
|
|
const select = document.querySelector('select[aria-label^="材质槽"]');
|
|
return select instanceof HTMLSelectElement && select.value.startsWith("material:black_worn_wall_brick_HQ");
|
|
}, undefined, { timeout: 180_000 });
|
|
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
|
|
await render(1024, "SCENE");
|
|
});
|
|
await chapter("summary", "总结与教案", async () => {
|
|
await page.waitForTimeout(1200);
|
|
});
|
|
}
|
|
|
|
const durationMs = Date.now() - recordingStart;
|
|
if (!smoke) {
|
|
if (durationMs < 430_000) {
|
|
await page.waitForTimeout(430_000 - durationMs);
|
|
}
|
|
else if (durationMs > 700_000) {
|
|
throw new Error(`recording too long for the 7-12 minute contract: ${durationMs}ms`);
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
fatalError = error;
|
|
errors.push(`fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
if (page) await page.screenshot({ path: path.join(DIAG, "failure-final.png") }).catch(() => undefined);
|
|
}
|
|
finally {
|
|
if (context) await context.close().catch(() => undefined);
|
|
let screencast = null;
|
|
if (video) {
|
|
const webmPath = path.join(OUT, "screencast.webm");
|
|
await video.saveAs(webmPath).catch((error) => errors.push(`video save: ${error instanceof Error ? error.message : String(error)}`));
|
|
if (fs.existsSync(webmPath)) {
|
|
screencast = { path: path.relative(REPO, webmPath), sha256: fileSha(webmPath), bytes: fs.statSync(webmPath).size };
|
|
}
|
|
}
|
|
const finalDurationMs = Date.now() - recordingStart;
|
|
const report = {
|
|
schemaVersion: 1,
|
|
status: !fatalError && !errors.length && screencast && (smoke || timeline.length === 13) ? "PASS" : "FAIL",
|
|
gpu: probe,
|
|
gpuMode,
|
|
smoke,
|
|
durationMs: finalDurationMs,
|
|
chapters: timeline,
|
|
errors,
|
|
screencast,
|
|
};
|
|
fs.writeFileSync(path.join(OUT, "timeline.json"), JSON.stringify(report, null, 2) + "\n");
|
|
fs.writeFileSync(path.join(OUT, "record-report.json"), JSON.stringify(report, null, 2) + "\n");
|
|
console.log(JSON.stringify({ status: report.status, durationMs: finalDurationMs, chapters: timeline.length, errors: errors.length, video: screencast?.path ?? null }, null, 2));
|
|
if (browser) await browser.close().catch(() => undefined);
|
|
if (server) server.kill("SIGTERM");
|
|
if (report.status !== "PASS") process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => { console.error(error); process.exitCode = 1; });
|