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) {
|
||||
|
||||
356
tools/web/capture-re-pbr-lesson.mjs
Normal file
356
tools/web/capture-re-pbr-lesson.mjs
Normal file
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headed Chromium capture for the RE 5000PBR + monkey-head render lesson.
|
||||
*
|
||||
* Usage:
|
||||
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
|
||||
* node tools/web/capture-re-pbr-lesson.mjs --dev --port 5177 --mode all
|
||||
*
|
||||
* Produces:
|
||||
* tests/golden/RE-PBR-RENDER-001/renders/*.png
|
||||
* tests/golden/RE-PBR-RENDER-001/report.json
|
||||
* web/app/public/tutorial/pbr/lesson/renders/*.jpg
|
||||
* web/app/public/tutorial/pbr/lesson/steps/*.jpg
|
||||
* web/app/public/tutorial/pbr/lesson/landing.jpg
|
||||
*/
|
||||
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 OUT = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
|
||||
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 port = Number.parseInt(flag("port", process.env.WEB_TEST_PORT ?? "5177"), 10) || 5177;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const mode = flag("mode", "all");
|
||||
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",
|
||||
];
|
||||
const swiftshaderArgs = [
|
||||
"--no-sandbox",
|
||||
"--use-gl=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--enable-unsafe-webgpu",
|
||||
"--enable-dawn-features=allow_unsafe_apis",
|
||||
"--use-webgpu-adapter=swiftshader",
|
||||
];
|
||||
|
||||
fs.mkdirSync(path.join(OUT, "renders"), { recursive: true });
|
||||
fs.mkdirSync(path.join(LESSON, "renders"), { recursive: true });
|
||||
fs.mkdirSync(path.join(LESSON, "steps"), { recursive: true });
|
||||
|
||||
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
||||
const sha256File = (file) => sha256(fs.readFileSync(file));
|
||||
|
||||
async function waitForServer(url, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual" });
|
||||
if (response.status < 500) return;
|
||||
}
|
||||
catch { /* retry */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
}
|
||||
throw new Error(`server did not start: ${url}`);
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
if (useDev) {
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(WEB, "node_modules/vite/bin/vite.js"),
|
||||
"--config", path.join(WEB, "app/vite.config.ts"),
|
||||
"--host", "127.0.0.1", "--port", String(port), "--strictPort",
|
||||
], { cwd: WEB, stdio: ["ignore", "pipe", "pipe"] });
|
||||
child.stdout.on("data", () => undefined);
|
||||
child.stderr.on("data", () => undefined);
|
||||
return child;
|
||||
}
|
||||
const child = 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) },
|
||||
});
|
||||
child.stdout.on("data", () => undefined);
|
||||
child.stderr.on("data", () => undefined);
|
||||
return child;
|
||||
}
|
||||
|
||||
const consoleErrors = [];
|
||||
|
||||
async function main() {
|
||||
const server = startServer();
|
||||
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer(`${baseUrl}/`);
|
||||
browser = await chromium.launch({
|
||||
headless: false,
|
||||
executablePath: chromePath,
|
||||
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
|
||||
});
|
||||
let context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
||||
let page = await context.newPage();
|
||||
const attachPage = () => {
|
||||
page.on("pageerror", (error) => consoleErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "error") return;
|
||||
const text = message.text();
|
||||
if (!/Content Security Policy|style-src/i.test(text)) consoleErrors.push(text);
|
||||
});
|
||||
};
|
||||
attachPage();
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "RE-PBR-RENDER-001",
|
||||
renderer: "WEB_WEBGL2",
|
||||
headed: true,
|
||||
gpuMode,
|
||||
browser: browser.version(),
|
||||
chromePath,
|
||||
display: process.env.DISPLAY ?? "",
|
||||
generatedAt: new Date().toISOString(),
|
||||
studioSha256: sha256File(STUDIO),
|
||||
glbSha256: sha256File(GLB),
|
||||
materials: MATERIALS,
|
||||
renders: [],
|
||||
steps: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const probeGpu = async () => {
|
||||
const 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");
|
||||
const webgpu = navigator.gpu
|
||||
? await navigator.gpu.requestAdapter().then((adapter) => adapter
|
||||
? { isFallbackAdapter: adapter.isFallbackAdapter, info: adapter.info ? { ...adapter.info } : null }
|
||||
: null).catch(() => null)
|
||||
: null;
|
||||
return {
|
||||
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED",
|
||||
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED",
|
||||
version: gl.getParameter(gl.VERSION),
|
||||
webgpu,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (!probe) throw new Error("GPU probe: no WebGL2 canvas found");
|
||||
report.webgl = { vendor: probe.vendor, renderer: probe.renderer, version: probe.version };
|
||||
report.webgpu = probe.webgpu;
|
||||
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) {
|
||||
throw new Error(`GPU probe failed: hardware mode got ${probe.renderer}`);
|
||||
}
|
||||
return probe;
|
||||
};
|
||||
|
||||
const objectCount = async () => {
|
||||
const text = await page.getByTestId("scene-stats").innerText();
|
||||
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
|
||||
};
|
||||
const openStudio = async () => {
|
||||
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
||||
await page.locator(".blender-app").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 tools = page.locator('[data-testid="native-web-tools-toggle"]');
|
||||
if (await tools.count()) await tools.click();
|
||||
};
|
||||
const importMonkey = async () => {
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
|
||||
try {
|
||||
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
|
||||
}
|
||||
catch (error) {
|
||||
await page.screenshot({ path: path.join(OUT, "failure-import.png"), type: "png" }).catch(() => undefined);
|
||||
const state = await page.evaluate(() => ({
|
||||
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
|
||||
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
|
||||
body: document.body.innerText.slice(0, 600),
|
||||
})).catch(() => null);
|
||||
console.error("IMPORT_FAIL", JSON.stringify({ state, consoleErrors: consoleErrors.slice(-8) }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const openShading = 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 });
|
||||
};
|
||||
const openRendering = async () => {
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
|
||||
await page.locator(".pbr-render-panel").waitFor({ timeout: 60_000 });
|
||||
};
|
||||
const assign = async (name) => {
|
||||
const card = page.locator(`[data-library="library:re-pbr-v1"]`, { hasText: name }).first();
|
||||
await card.waitFor({ timeout: 60_000 });
|
||||
await card.dblclick();
|
||||
await page.waitForFunction((expected) => {
|
||||
const select = document.querySelector('select[aria-label^="材质槽"]');
|
||||
return select instanceof HTMLSelectElement && select.value === `material:${expected}`;
|
||||
}, name, { timeout: 180_000 });
|
||||
await page.waitForFunction(() => document.querySelector(".pbr-asset-grid")?.getAttribute("aria-busy") === "false", undefined, { timeout: 180_000 });
|
||||
};
|
||||
const render = async (resolution, camera, name, compileStatus = "") => {
|
||||
await openRendering();
|
||||
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
|
||||
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
|
||||
await page.locator('select[aria-label="PBR 视角预设"]').selectOption(camera === "VIEWPORT" ? "COMPARISON" : "SCENE_CAMERA");
|
||||
const previousSrc = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await page.waitForFunction(({ expected, previousSrc }) => {
|
||||
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") !== previousSrc
|
||||
&& button?.textContent?.includes("渲染 PBR");
|
||||
}, { expected: resolution, previousSrc }, { timeout: 180_000 });
|
||||
const panel = page.locator(".pbr-render-panel");
|
||||
const textureStatus = await panel.getAttribute("data-texture-status");
|
||||
const textureAssets = Number(await panel.getAttribute("data-texture-assets") ?? "0");
|
||||
const stats = await page.evaluate(() => {
|
||||
const image = document.querySelector(".pbr-render-result img");
|
||||
if (!(image instanceof HTMLImageElement)) throw new Error("render image missing");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("2d canvas unavailable");
|
||||
context.drawImage(image, 0, 0);
|
||||
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let sum = 0;
|
||||
let sumSquared = 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const luminance = (data[index] + data[index + 1] + data[index + 2]) / 3;
|
||||
sum += luminance;
|
||||
sumSquared += luminance * luminance;
|
||||
count += 1;
|
||||
}
|
||||
const mean = sum / Math.max(1, count);
|
||||
return { mean, std: Math.sqrt(Math.max(0, sumSquared / Math.max(1, count) - mean * mean)), jpeg: canvas.toDataURL("image/jpeg", 0.86) };
|
||||
});
|
||||
const pngPath = path.join(OUT, "renders", `${name}.png`);
|
||||
const [download] = await Promise.all([page.waitForEvent("download"), page.locator(".pbr-render-download").click()]);
|
||||
await download.saveAs(pngPath);
|
||||
const jpgPath = path.join(LESSON, "renders", `${name}.jpg`);
|
||||
fs.writeFileSync(jpgPath, Buffer.from(stats.jpeg.split(",")[1], "base64"));
|
||||
const entry = {
|
||||
name, material: name.startsWith("material-") ? name.slice("material-".length) : null,
|
||||
resolution, camera, path: path.relative(REPO, pngPath), jpg: path.relative(REPO, jpgPath),
|
||||
sha256: sha256File(pngPath), byteLength: fs.statSync(pngPath).size,
|
||||
mean: Number(stats.mean.toFixed(4)), std: Number(stats.std.toFixed(4)),
|
||||
textureStatus, textureAssets, compileStatus,
|
||||
};
|
||||
report.renders.push(entry);
|
||||
return entry;
|
||||
};
|
||||
const step = async (name) => {
|
||||
const file = path.join(LESSON, "steps", `${name}.jpg`);
|
||||
await page.screenshot({ path: file, type: "jpeg", quality: 82 });
|
||||
report.steps.push({ name, path: path.relative(REPO, file), sha256: sha256File(file) });
|
||||
};
|
||||
|
||||
// Baseline: studio + imported monkey, no library material.
|
||||
await openStudio();
|
||||
await probeGpu();
|
||||
await step("01-open-studio");
|
||||
await importMonkey();
|
||||
await step("02-import-glb");
|
||||
await render(1024, "SCENE", "baseline-unassigned-1024-scene", "BASELINE");
|
||||
|
||||
for (const name of MATERIALS) {
|
||||
await context.close();
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
||||
page = await context.newPage();
|
||||
attachPage();
|
||||
await openStudio();
|
||||
await importMonkey();
|
||||
await openShading();
|
||||
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
|
||||
await step(`03-asset-library-${name}`);
|
||||
await assign(name);
|
||||
await page.waitForFunction(() => /COMPILED|BLOCKED/.test(document.querySelector(".material-preview-status")?.textContent ?? ""), undefined, { timeout: 60_000 });
|
||||
const compileText = (await page.locator(".material-preview-status").innerText({ timeout: 60_000 })).trim();
|
||||
const compileStatus = compileText.split("\n").find((line) => /COMPILED|BLOCKED/.test(line))?.trim() ?? compileText;
|
||||
await step(`04-assign-${name}`);
|
||||
await render(1024, "SCENE", `material-${name}-1024-scene`, compileStatus);
|
||||
await render(1024, "VIEWPORT", `material-${name}-1024-viewport`, compileStatus);
|
||||
await render(512, "SCENE", `material-${name}-512-scene`, compileStatus);
|
||||
}
|
||||
|
||||
// Saved/reopened project re-render (last material persists in the .blend).
|
||||
const saved = path.join(OUT, "re-pbr-lesson-saved.blend");
|
||||
await openRendering();
|
||||
const [blendDownload] = await Promise.all([page.waitForEvent("download"), page.getByRole("button", { name: "保存项目", exact: true }).click()]);
|
||||
await blendDownload.saveAs(saved);
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(saved);
|
||||
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 step("05-reopen");
|
||||
await render(1024, "SCENE", `material-${MATERIALS[MATERIALS.length - 1]}-reopen-1024-scene`, "REOPENED");
|
||||
|
||||
await page.screenshot({ path: path.join(LESSON, "landing.jpg"), type: "jpeg", quality: 84 });
|
||||
|
||||
report.status = report.renders.length === 20 && report.errors.length === 0 ? "PASS" : "FAIL";
|
||||
report.consoleErrors = consoleErrors;
|
||||
fs.writeFileSync(path.join(OUT, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({
|
||||
status: report.status,
|
||||
renders: report.renders.length,
|
||||
steps: report.steps.length,
|
||||
errors: report.errors.length + consoleErrors.length,
|
||||
report: path.relative(REPO, path.join(OUT, "report.json")),
|
||||
}, null, 2));
|
||||
if (report.status !== "PASS") process.exitCode = 1;
|
||||
}
|
||||
finally {
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
server.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -23,7 +23,7 @@ assert.deepEqual(contract.responseHeaders.allResponses, {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
});
|
||||
const routes = Object.fromEntries(contract.responseHeaders.routes.map((route) => [route.id, route]));
|
||||
assert.deepEqual(routes["entry-document"], { id: "entry-document", patterns: ["/", "/index.html"], cacheControl: "no-cache" });
|
||||
@@ -52,11 +52,12 @@ for (const [extension, mime] of Object.entries({
|
||||
".ogg": "audio/ogg",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".vtt": "text/vtt; charset=utf-8",
|
||||
".blend": "application/octet-stream",
|
||||
".nvdb": "application/x-nanovdb",
|
||||
})) assert.equal(contract.mimeTypes[extension], mime, `${extension} MIME drifted`);
|
||||
|
||||
assert.deepEqual(contract.rangeRequests.extensions, [".wasm", ".blend", ".nvdb"]);
|
||||
assert.deepEqual(contract.rangeRequests.extensions, [".wasm", ".blend", ".nvdb", ".mp4", ".webm"]);
|
||||
assert.equal(contract.rangeRequests.unit, "bytes");
|
||||
assert.equal(contract.rangeRequests.singleRangeOnly, true);
|
||||
assert.equal(contract.rangeRequests.etag, "strong-sha256");
|
||||
|
||||
@@ -41,6 +41,8 @@ const fixtures = new Map([
|
||||
["/engine-manifest.json", write("engine-manifest.json", "{\"schemaVersion\":2}\n")],
|
||||
["/media/frame.png", write("media/frame.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]))],
|
||||
["/media/silence.wav", write("media/silence.wav", Buffer.from("RIFF0000WAVE"))],
|
||||
["/media/lesson.mp4", write("media/lesson.mp4", deterministicBytes(4096, 17))],
|
||||
["/media/lesson.vtt", write("media/lesson.vtt", "WEBVTT\n\n00:00.000 --> 00:02.000\nHello\n")],
|
||||
["/fixtures/basic.blend", write("fixtures/basic.blend", deterministicBytes(4096, 11))],
|
||||
["/volumes/smoke.nvdb", write("volumes/smoke.nvdb", deterministicBytes(1024 * 1024, 19))],
|
||||
]);
|
||||
@@ -111,6 +113,8 @@ try {
|
||||
["/engine-manifest.json", ".json"],
|
||||
["/media/frame.png", ".png"],
|
||||
["/media/silence.wav", ".wav"],
|
||||
["/media/lesson.mp4", ".mp4"],
|
||||
["/media/lesson.vtt", ".vtt"],
|
||||
["/fixtures/basic.blend", ".blend"],
|
||||
["/volumes/smoke.nvdb", ".nvdb"],
|
||||
];
|
||||
@@ -134,6 +138,7 @@ try {
|
||||
await expectRange("/vendor/blender/single/web_engine.wasm", "bytes=100-199", 100, 199);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=200-399", 200, 399);
|
||||
await expectRange("/volumes/smoke.nvdb", "bytes=300-599", 300, 599);
|
||||
await expectRange("/media/lesson.mp4", "bytes=100-199", 100, 199);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=400-", 400, fixtures.get("/fixtures/basic.blend").length - 1);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=-128", fixtures.get("/fixtures/basic.blend").length - 128, fixtures.get("/fixtures/basic.blend").length - 1);
|
||||
|
||||
@@ -189,7 +194,7 @@ try {
|
||||
assert.equal((await fetch(`${privateOrigin}/api/server-operator-job`, { method: "POST", headers: { ...privateHeaders, Origin: "https://untrusted.invalid" } })).status, 403);
|
||||
await assert.rejects(listenDeploymentHttpServer(createDeploymentHttpServer({ root, contract, accessPassword: "" }), { host: "0.0.0.0" }), /WEB_BLENDER_PASSWORD/);
|
||||
} finally { await new Promise((resolve) => privateServer.close(resolve)); }
|
||||
process.stdout.write("deployment-http-ok mime=10 head=4 ranges=5 invalidRanges=6 resumeSha256=verified\n");
|
||||
process.stdout.write("deployment-http-ok mime=12 head=4 ranges=6 invalidRanges=6 resumeSha256=verified\n");
|
||||
}
|
||||
finally {
|
||||
if (server.listening) await new Promise((resolve) => server.close(resolve));
|
||||
|
||||
99
tools/web/check-pbr-tutorial-captures.mjs
Normal file
99
tools/web/check-pbr-tutorial-captures.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const assets = path.join(repoRoot, "web/app/public/tutorial/pbr");
|
||||
const stepsDir = path.join(assets, "steps");
|
||||
const rendersDir = path.join(assets, "renders");
|
||||
const reportDir = path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001");
|
||||
const stepIds = [
|
||||
"intro", "workspace-shading", "load-studio", "add-suzanne", "add-cube", "add-torus",
|
||||
"assign-metal", "assign-glass", "assign-wood", "assign-emission",
|
||||
"render-setup", "download-save", "summary",
|
||||
];
|
||||
const models = ["suzanne", "cube", "torus"];
|
||||
const materials = ["metal-brushed", "glass", "wood", "emission-cyan"];
|
||||
const errors = [];
|
||||
|
||||
function requireFile(file, minimum = 1) {
|
||||
if (!fs.existsSync(file)) {
|
||||
errors.push(`missing: ${path.relative(repoRoot, file)}`);
|
||||
return;
|
||||
}
|
||||
if (fs.statSync(file).size < minimum) errors.push(`empty: ${path.relative(repoRoot, file)}`);
|
||||
}
|
||||
|
||||
function pngSize(file) {
|
||||
const bytes = fs.readFileSync(file);
|
||||
if (bytes.length < 24 || bytes.toString("ascii", 1, 4) !== "PNG") throw new Error(`not a PNG: ${file}`);
|
||||
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
for (const stepId of stepIds) {
|
||||
requireFile(path.join(stepsDir, `${stepId}.jpg`), 1024);
|
||||
requireFile(path.join(stepsDir, `${stepId}-view.jpg`), 1024);
|
||||
}
|
||||
requireFile(path.join(assets, "landing.jpg"), 1024);
|
||||
requireFile(path.join(stepsDir, "00-assembly.jpg"), 1024);
|
||||
|
||||
for (const model of models) {
|
||||
for (const material of materials) {
|
||||
const png = path.join(rendersDir, `${model}-${material}.png`);
|
||||
const jpg = path.join(rendersDir, `${model}-${material}.jpg`);
|
||||
requireFile(png, 1024);
|
||||
requireFile(jpg, 1024);
|
||||
if (fs.existsSync(png)) {
|
||||
try {
|
||||
const { width, height } = pngSize(png);
|
||||
if (width !== 1024 || height !== 1024) errors.push(`${path.basename(png)} is ${width}x${height}, expected 1024x1024`);
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const studioBlend = path.join(assets, "studio.blend");
|
||||
const studioReport = path.join(reportDir, "studio-build.json");
|
||||
requireFile(studioBlend, 1024);
|
||||
requireFile(studioReport);
|
||||
if (fs.existsSync(studioBlend) && fs.existsSync(studioReport)) {
|
||||
const report = JSON.parse(fs.readFileSync(studioReport, "utf8"));
|
||||
if (report.blend?.sha256 !== sha256(studioBlend)) errors.push("studio-build.json SHA-256 does not match studio.blend");
|
||||
}
|
||||
|
||||
const latestReport = path.join(reportDir, "headed-screenshots-latest.json");
|
||||
requireFile(latestReport);
|
||||
if (fs.existsSync(latestReport)) {
|
||||
const report = JSON.parse(fs.readFileSync(latestReport, "utf8"));
|
||||
if (report.status !== "PASS") errors.push(`headed report status is ${report.status}`);
|
||||
if (report.course !== "pbr") errors.push("headed report course is not pbr");
|
||||
if ((report.steps?.length ?? 0) !== stepIds.length) errors.push(`headed report has ${report.steps?.length ?? 0} steps`);
|
||||
if ((report.renders?.length ?? 0) !== 12) errors.push(`headed report has ${report.renders?.length ?? 0} renders`);
|
||||
if ((report.errors?.length ?? 0) !== 0) errors.push(`headed report has ${report.errors.length} errors`);
|
||||
}
|
||||
|
||||
const renderManifest = path.join(rendersDir, "manifest.json");
|
||||
requireFile(renderManifest);
|
||||
if (fs.existsSync(renderManifest)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(renderManifest, "utf8"));
|
||||
if ((manifest.renders?.length ?? 0) !== 12) errors.push(`render manifest has ${manifest.renders?.length ?? 0} entries`);
|
||||
for (const entry of manifest.renders ?? []) {
|
||||
if (!fs.existsSync(path.join(repoRoot, entry.png))) errors.push(`render manifest missing ${entry.png}`);
|
||||
if (!fs.existsSync(path.join(repoRoot, entry.jpg))) errors.push(`render manifest missing ${entry.jpg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(JSON.stringify({ status: "FAIL", errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ status: "PASS", steps: stepIds.length, renders: 12 }, null, 2));
|
||||
106
tools/web/check-re-pbr-lesson.mjs
Normal file
106
tools/web/check-re-pbr-lesson.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/** Validate the RE 5000PBR render matrix, lesson assets and static HTML. */
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
const OUT = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
|
||||
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr");
|
||||
const GLB_SOURCE = path.join(REPO, "re/猴头.glb");
|
||||
const GLB_FIXTURE = path.join(REPO, "tests/files/web/pbr-render-lesson/monkey-head.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 sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
const pngSize = (file) => {
|
||||
const bytes = fs.readFileSync(file);
|
||||
assert.equal(bytes.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", `not a PNG: ${file}`);
|
||||
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||
};
|
||||
|
||||
const report = readJson(path.join(OUT, "report.json"));
|
||||
assert.equal(report.status, "PASS");
|
||||
assert.equal(report.renderer, "WEB_WEBGL2");
|
||||
assert.equal(report.headed, true);
|
||||
assert.equal(report.renders.length, 20, "expected 20 renders");
|
||||
assert.equal(report.steps.length, 15, "expected 15 lesson steps");
|
||||
assert.equal(report.errors.length, 0);
|
||||
assert.equal(sha256(fs.readFileSync(GLB_SOURCE)), sha256(fs.readFileSync(GLB_FIXTURE)), "GLB fixture SHA mismatch");
|
||||
assert.equal(report.glbSha256, sha256(fs.readFileSync(GLB_FIXTURE)));
|
||||
|
||||
const materialScene = new Map();
|
||||
for (const render of report.renders) {
|
||||
const file = path.join(REPO, render.path);
|
||||
assert.ok(fs.existsSync(file), `missing render: ${render.path}`);
|
||||
assert.equal(sha256(fs.readFileSync(file)), render.sha256, `render SHA mismatch: ${render.path}`);
|
||||
assert.equal(fs.statSync(file).size, render.byteLength, `render byte length mismatch: ${render.path}`);
|
||||
const size = pngSize(file);
|
||||
assert.equal(size.width, render.resolution, `render width mismatch: ${render.path}`);
|
||||
assert.equal(size.height, render.resolution, `render height mismatch: ${render.path}`);
|
||||
assert.ok(render.mean > 1 && render.mean < 254, `render appears blank: ${render.path} mean=${render.mean}`);
|
||||
assert.ok(render.textureStatus?.startsWith("ready:"), `texture status not ready: ${render.path} ${render.textureStatus}`);
|
||||
assert.ok(render.textureAssets > 0, `no texture assets: ${render.path}`);
|
||||
if (render.camera === "SCENE" && render.resolution === 1024 && render.material && !render.name.includes("reopen")) {
|
||||
materialScene.set(render.material, render.sha256);
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(REPO, render.jpg)), `missing lesson JPG: ${render.jpg}`);
|
||||
}
|
||||
assert.equal(materialScene.size, 6, "missing 1024 scene renders for six materials");
|
||||
assert.ok(report.renders.every((render) => !String(render.compileStatus ?? "").includes("BLOCKED")), "a material compile was BLOCKED");
|
||||
assert.ok(report.renders.filter((render) => String(render.compileStatus ?? "").includes("COMPILED")).length >= 6, "six RE materials must compile");
|
||||
assert.equal(new Set(materialScene.values()).size, 6, "six material renders are not distinct");
|
||||
const baseline = report.renders.find((render) => render.name === "baseline-unassigned-1024-scene");
|
||||
assert.ok(baseline, "baseline render missing");
|
||||
assert.ok(![...materialScene.values()].includes(baseline.sha256), "baseline identical to a material render");
|
||||
assert.ok(report.renders.some((render) => render.name.endsWith("-reopen-1024-scene")), "reopen render missing");
|
||||
|
||||
const htmlPath = path.join(LESSON, "render-lesson.html");
|
||||
const cssPath = path.join(LESSON, "render-lesson.css");
|
||||
assert.ok(fs.existsSync(htmlPath), "render-lesson.html missing");
|
||||
assert.ok(fs.existsSync(cssPath), "render-lesson.css missing");
|
||||
const html = fs.readFileSync(htmlPath, "utf8");
|
||||
assert.ok(!/<script\b/i.test(html), "lesson must not contain inline scripts");
|
||||
assert.ok(!/<style\b/i.test(html), "lesson must not contain inline style blocks");
|
||||
assert.ok(!/\son[a-z]+\s*=/i.test(html), "lesson must not contain inline on* handlers");
|
||||
assert.ok(!/https?:\/\//i.test(html), "lesson must not reference remote URLs");
|
||||
assert.ok(html.includes("20 张 Web/WebGL2 成片"), "lesson must state the 20-render result");
|
||||
|
||||
const sources = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((match) => match[1]);
|
||||
for (const source of sources) {
|
||||
if (source.startsWith("#") || source.startsWith("/?") || source.startsWith("mailto:")) continue;
|
||||
assert.ok(source.startsWith("/"), `relative lesson URL is not supported: ${source}`);
|
||||
const file = path.join(REPO, "web/app/public", source.slice(1));
|
||||
assert.ok(fs.existsSync(file), `lesson asset missing: ${source}`);
|
||||
}
|
||||
for (const match of html.matchAll(/poster="([^"]+)"/g)) {
|
||||
const source = match[1];
|
||||
assert.ok(source.startsWith("/"), `relative poster is not supported: ${source}`);
|
||||
assert.ok(fs.existsSync(path.join(REPO, "web/app/public", source.slice(1))), `lesson poster missing: ${source}`);
|
||||
}
|
||||
for (const material of MATERIALS) {
|
||||
assert.ok(html.includes(`material-${material}-1024-scene.jpg`), `lesson misses ${material} render`);
|
||||
assert.ok(html.includes(material), `lesson misses ${material} name`);
|
||||
}
|
||||
for (const step of report.steps) {
|
||||
assert.ok(fs.existsSync(path.join(REPO, step.path)), `missing step image: ${step.path}`);
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(LESSON, "lesson/landing.jpg")), "landing image missing");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
status: "PASS",
|
||||
renders: report.renders.length,
|
||||
steps: report.steps.length,
|
||||
materials: materialScene.size,
|
||||
baselineMean: baseline.mean,
|
||||
lesson: path.relative(REPO, htmlPath),
|
||||
}, null, 2));
|
||||
130
tools/web/check-re-pbr-library.mjs
Normal file
130
tools/web/check-re-pbr-library.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/** Validate the derived RE 5000PBR asset library and its preview/manifest identity. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
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 HQ = args.includes("--hq");
|
||||
const DIR = path.resolve(args.find((value) => !value.startsWith("--") && value !== "hq") ?? flag("dir", path.join(REPO, HQ ? "web/app/public/assets/re-pbr-hq" : "web/app/public/assets/re-pbr")));
|
||||
const BLENDER = path.join(REPO, "build_blender_native/bin/blender");
|
||||
const LIBRARY_ID = flag("library-id", HQ ? "library:re-pbr-hq-v1" : "library:re-pbr-v1");
|
||||
const LIBRARY_NAME = flag("library-name", HQ ? "RE 5000PBR HQ" : "RE 5000PBR");
|
||||
const LIBRARY_SOURCE = flag("source", HQ ? "libraries/re-pbr-hq-library.blend" : "libraries/re-pbr-library.blend");
|
||||
const EXPECTED_MATERIALS = 6;
|
||||
const MAX_LIBRARY_BYTES = Number(flag("max-library-bytes", HQ ? 16 * 1024 * 1024 : 5 * 1024 * 1024));
|
||||
const MAX_PREVIEW_BYTES = 128 * 1024;
|
||||
const MAX_TEXTURE_SIZE = Number(flag("max-texture-size", HQ ? 2048 : 1024));
|
||||
const SUFFIX = flag("suffix", HQ ? "_HQ" : "");
|
||||
|
||||
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
||||
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
|
||||
const assertAscii = (name, value) => assert.match(value, /^[\x20-\x7e]+$/, `${name} must be ASCII: ${value}`);
|
||||
|
||||
const manifest = readJson("manifest.json");
|
||||
const report = readJson("build-report.json");
|
||||
const previewManifest = readJson("preview-manifest.json");
|
||||
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.libraries.length, 1);
|
||||
assert.equal(manifest.libraries[0].libraryId, LIBRARY_ID);
|
||||
assert.equal(manifest.libraries[0].name, LIBRARY_NAME);
|
||||
assert.equal(manifest.libraries[0].sourcePath, LIBRARY_SOURCE);
|
||||
assert.equal(manifest.libraries[0].readOnly, true);
|
||||
assert.equal(manifest.assets.length, EXPECTED_MATERIALS);
|
||||
assert.ok(manifest.assets.every((asset) => asset.idType === "MATERIAL"), "all assets must be MATERIAL");
|
||||
assert.ok(manifest.assets.every((asset) => asset.assetLibraryIdentifier === LIBRARY_ID));
|
||||
assert.ok(manifest.assets.every((asset) => asset.preview), "every asset needs a preview");
|
||||
|
||||
const libraryPath = path.join(DIR, "re-pbr-library.blend");
|
||||
const libraryBytes = fs.readFileSync(libraryPath);
|
||||
const librarySha = sha256(libraryBytes);
|
||||
assert.equal(librarySha, manifest.libraries[0].sourceSha256, "manifest library SHA mismatch");
|
||||
assert.equal(librarySha, report.library.sha256, "build report library SHA mismatch");
|
||||
assert.ok(libraryBytes.byteLength <= MAX_LIBRARY_BYTES, `library exceeds ${MAX_LIBRARY_BYTES} bytes`);
|
||||
assert.equal(report.materials.length, EXPECTED_MATERIALS);
|
||||
assert.equal(previewManifest.previews.length, EXPECTED_MATERIALS);
|
||||
|
||||
for (const name of ["manifest.json", "build-report.json", "preview-manifest.json", "blender_assets.cats.txt", "re-pbr-library.blend"]) {
|
||||
assertAscii("file", name);
|
||||
}
|
||||
for (const asset of manifest.assets) {
|
||||
assertAscii("asset name", asset.name);
|
||||
assert.ok(asset.name !== "Dots Stroke", "Dots Stroke must not be exported");
|
||||
if (SUFFIX) assert.ok(asset.name.endsWith(SUFFIX), `asset name misses suffix ${SUFFIX}: ${asset.name}`);
|
||||
assert.ok(asset.preview.byteLength <= MAX_PREVIEW_BYTES, `preview too large: ${asset.name}`);
|
||||
const previewPath = path.join(DIR, "previews", `${asset.preview.sha256}.png`);
|
||||
const previewBytes = fs.readFileSync(previewPath);
|
||||
assert.equal(sha256(previewBytes), asset.preview.sha256, `preview SHA mismatch: ${asset.name}`);
|
||||
assert.equal(previewBytes.byteLength, asset.preview.byteLength, `preview size mismatch: ${asset.name}`);
|
||||
assert.equal(asset.preview.width, 256);
|
||||
assert.equal(asset.preview.height, 256);
|
||||
}
|
||||
|
||||
const blenderExpr = `
|
||||
import bpy, json
|
||||
result = {"materials": {}, "errors": []}
|
||||
expected = {"FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"}
|
||||
for material in bpy.data.materials:
|
||||
if material.name == "Dots Stroke" or not material.use_nodes or material.node_tree is None:
|
||||
continue
|
||||
types = {node.type for node in material.node_tree.nodes}
|
||||
images = []
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||
image = node.image
|
||||
images.append({
|
||||
"name": image.name,
|
||||
"size": list(image.size[:]),
|
||||
"colorspace": image.colorspace_settings.name,
|
||||
"packed": bool(image.packed_file),
|
||||
"packedBytes": image.packed_file.size if image.packed_file else 0,
|
||||
"fileFormat": image.file_format,
|
||||
})
|
||||
result["materials"][material.name] = {"types": sorted(types), "images": images}
|
||||
print("CHECK_JSON:" + json.dumps(result))
|
||||
`;
|
||||
const output = execFileSync(BLENDER, ["-b", libraryPath, "--python-expr", blenderExpr], {
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const line = output.split(/\r?\n/).find((value) => value.startsWith("CHECK_JSON:"));
|
||||
assert.ok(line, "Blender inspection produced no CHECK_JSON line");
|
||||
const inspection = JSON.parse(line.slice("CHECK_JSON:".length));
|
||||
assert.equal(Object.keys(inspection.materials).length, EXPECTED_MATERIALS);
|
||||
for (const [name, info] of Object.entries(inspection.materials)) {
|
||||
const types = new Set(info.types);
|
||||
for (const expectedType of ["FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"]) {
|
||||
assert.ok(types.has(expectedType), `${name} lost node ${expectedType}`);
|
||||
}
|
||||
assert.equal(info.images.length, 4, `${name} must keep four image textures`);
|
||||
for (const image of info.images) {
|
||||
assert.ok(image.packed, `${image.name} is not packed`);
|
||||
assert.ok(image.size[0] > 0 && image.size[1] > 0, `${image.name} has no pixels`);
|
||||
assert.ok(image.size[0] <= MAX_TEXTURE_SIZE && image.size[1] <= MAX_TEXTURE_SIZE, `${image.name} exceeds ${MAX_TEXTURE_SIZE}px`);
|
||||
assert.equal(image.fileFormat, "JPEG", `${image.name} is not JPEG`);
|
||||
assert.ok(image.colorspace === "sRGB" || image.colorspace === "Non-Color", `${image.name} colorspace ${image.colorspace}`);
|
||||
if (image.name.toLowerCase().includes("base_color")) assert.equal(image.colorspace, "sRGB");
|
||||
else assert.equal(image.colorspace, "Non-Color");
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
status: "PASS",
|
||||
libraryId: LIBRARY_ID,
|
||||
librarySha256: librarySha,
|
||||
libraryBytes: libraryBytes.byteLength,
|
||||
materials: manifest.assets.length,
|
||||
previews: manifest.assets.length,
|
||||
previewBytes: manifest.assets.reduce((sum, asset) => sum + asset.preview.byteLength, 0),
|
||||
textureBytes: report.textures.reduce((sum, item) => sum + item.packedBytes, 0),
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
99
tools/web/check-re-pbr-limits.mjs
Normal file
99
tools/web/check-re-pbr-limits.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/** Validate the RE 5000PBR limitation fixes: displacement, diagnostics, HQ, GPU and camera. */
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
const LIMITS = path.join(REPO, "tests/golden/RE-PBR-LIMITS-001");
|
||||
const RENDER = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const exists = (file, label) => assert.ok(fs.existsSync(file), `missing ${label}: ${path.relative(REPO, file)}`);
|
||||
|
||||
const displacement = readJson(path.join(LIMITS, "displacement/report.json"));
|
||||
assert.equal(displacement.status, "PASS");
|
||||
assert.equal(displacement.nativeBinding.usage, "HEIGHT");
|
||||
assert.ok(Math.abs(displacement.nativeBinding.scale - 0.001) < 1e-6);
|
||||
assert.ok(Math.abs(displacement.nativeBinding.midlevel - 0.5) < 1e-6);
|
||||
assert.equal(displacement.subdivision.sourceTriangles, 968);
|
||||
assert.equal(displacement.subdivision.renderTriangles, 61952);
|
||||
for (const key of ["1x", "100x"]) {
|
||||
const render = displacement.renders[key];
|
||||
const file = path.join(REPO, render.path);
|
||||
exists(file, `displacement ${key}`);
|
||||
assert.equal(sha256(file), render.sha256, `displacement ${key} SHA`);
|
||||
assert.ok(render.mean > 1 && render.mean < 254);
|
||||
}
|
||||
assert.notEqual(displacement.renders["1x"].sha256, displacement.renders["100x"].sha256);
|
||||
assert.ok(displacement.meanDelta > 0.01, "displacement 1x/100x mean delta too small");
|
||||
|
||||
const diagnostics = readJson(path.join(LIMITS, "diagnostics/report.json"));
|
||||
assert.equal(diagnostics.status, "PASS");
|
||||
assert.equal(diagnostics.views.length, 3);
|
||||
for (const view of diagnostics.views) {
|
||||
const file = path.join(REPO, view.path);
|
||||
exists(file, `diagnostic ${view.mode}`);
|
||||
assert.equal(sha256(file), view.sha256, `diagnostic ${view.mode} SHA`);
|
||||
assert.ok(view.mean > 1 && view.mean < 254);
|
||||
}
|
||||
assert.equal(diagnostics.exposure.result, "PASS");
|
||||
assert.equal(new Set(diagnostics.views.map((view) => view.sha256)).size, 3, "diagnostic views must differ");
|
||||
|
||||
const hq = readJson(path.join(LIMITS, "hq/report.json"));
|
||||
assert.equal(hq.status, "PASS");
|
||||
exists(path.join(REPO, hq.library.path), "HQ library");
|
||||
assert.equal(sha256(path.join(REPO, hq.library.path)), hq.library.sha256);
|
||||
assert.equal(hq.budget.loaded, 5);
|
||||
assert.equal(hq.budget.rejected, 0);
|
||||
assert.ok(hq.budget.gpuDecodedBytes < 512 * 1024 * 1024, "HQ exceeds WebGL2 decoded budget");
|
||||
exists(path.join(REPO, hq.render.path), "HQ render");
|
||||
assert.equal(sha256(path.join(REPO, hq.render.path)), hq.render.sha256);
|
||||
|
||||
const gpu = readJson(path.join(LIMITS, "gpu/batch0-gpu-enable.json"));
|
||||
assert.equal(gpu.status, "PASS");
|
||||
assert.match(gpu.webgl.renderer, /NVIDIA|AMD|Intel|ANGLE/);
|
||||
assert.doesNotMatch(gpu.webgl.renderer, /SwiftShader|llvmpipe|software/i);
|
||||
const hardwareRender = gpu.render.hardware;
|
||||
const hardwareFile = path.join(REPO, hardwareRender.path);
|
||||
exists(hardwareFile, "GPU hardware render");
|
||||
assert.equal(sha256(hardwareFile), hardwareRender.sha256);
|
||||
assert.match(gpu.render.swiftshader.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.ok(gpu.render.meanStd.swiftshader.mean > 1 && gpu.render.meanStd.swiftshader.mean < 254);
|
||||
|
||||
const renderReport = readJson(path.join(RENDER, "report.json"));
|
||||
assert.equal(renderReport.status, "PASS");
|
||||
assert.equal(renderReport.renders.length, 20);
|
||||
assert.equal(new Set(renderReport.renders.map((render) => render.sha256)).size, 20, "20 renders must be unique");
|
||||
assert.match(renderReport.webgl.renderer, /NVIDIA|AMD|Intel|ANGLE/);
|
||||
const last = "material-black_worn_wall_brick";
|
||||
const scene = renderReport.renders.find((render) => render.name === `${last}-1024-scene`);
|
||||
const viewport = renderReport.renders.find((render) => render.name === `${last}-1024-viewport`);
|
||||
assert.ok(scene && viewport);
|
||||
assert.notEqual(scene.sha256, viewport.sha256, "SCENE and COMPARISON renders must differ");
|
||||
const reopen = renderReport.renders.find((render) => render.name.endsWith("-reopen-1024-scene"));
|
||||
assert.ok(reopen);
|
||||
assert.match(reopen.textureStatus, /^ready:([1-9]\d*):0:/);
|
||||
|
||||
const lessonHtml = path.join(REPO, "web/app/public/tutorial/pbr/render-lesson.html");
|
||||
exists(lessonHtml, "lesson HTML");
|
||||
const html = fs.readFileSync(lessonHtml, "utf8");
|
||||
for (const required of ["7. 进阶", "位移与细分", "诊断视图", "高清 2048", "RTX 4060"]) {
|
||||
assert.ok(html.includes(required), `lesson misses ${required}`);
|
||||
}
|
||||
for (const source of [...html.matchAll(/src="([^"]+)"/g)].map((match) => match[1])) {
|
||||
if (!source.startsWith("/")) continue;
|
||||
exists(path.join(REPO, "web/app/public", source.slice(1)), `lesson asset ${source}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
status: "PASS",
|
||||
displacement: { scale: displacement.nativeBinding.scale, triangles: displacement.subdivision.renderTriangles, meanDelta: displacement.meanDelta },
|
||||
diagnostics: diagnostics.views.length,
|
||||
hq: { bytes: hq.library.bytes, gpuDecodedBytes: hq.budget.gpuDecodedBytes },
|
||||
gpu: gpu.webgl.renderer,
|
||||
renders: renderReport.renders.length,
|
||||
uniqueRenders: 20,
|
||||
reopen: reopen.textureStatus,
|
||||
}, null, 2));
|
||||
95
tools/web/check-re-pbr-video.mjs
Normal file
95
tools/web/check-re-pbr-video.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/** Validate the RE 5000PBR lesson MP4, narration, VTT, poster and chapter screenshots. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
const OUT = path.join(REPO, "tests/golden/RE-PBR-VIDEO-001");
|
||||
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
|
||||
const VIDEO_DIR = path.join(LESSON, "video");
|
||||
const VIDEO = path.join(VIDEO_DIR, "re-pbr-lesson-720p.mp4");
|
||||
const VTT = path.join(VIDEO_DIR, "re-pbr-lesson.zh-Hans.vtt");
|
||||
const POSTER = path.join(VIDEO_DIR, "poster.jpg");
|
||||
const HTML = path.join(REPO, "web/app/public/tutorial/pbr/render-lesson.html");
|
||||
const FFPROBE = "/usr/bin/ffprobe";
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const exists = (file, label) => assert.ok(fs.existsSync(file), `missing ${label}: ${path.relative(REPO, file)}`);
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const probe = (file) => JSON.parse(execFileSync(FFPROBE, ["-v", "error", "-show_entries",
|
||||
"format=duration,size,format_name:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" }));
|
||||
|
||||
const report = readJson(path.join(OUT, "video-report.json"));
|
||||
const timeline = readJson(path.join(OUT, "timeline.json"));
|
||||
const narration = readJson(path.join(OUT, "narration.json"));
|
||||
assert.equal(report.status, "PASS");
|
||||
assert.equal(timeline.status, "PASS");
|
||||
assert.equal(report.chapters.length, 13);
|
||||
assert.equal(timeline.chapters.length, 13);
|
||||
assert.equal(narration.chapters.length, 13);
|
||||
for (const file of [VIDEO, VTT, POSTER]) exists(file, path.basename(file));
|
||||
assert.equal(sha256(VIDEO), report.video.sha256, "video SHA mismatch");
|
||||
assert.equal(sha256(POSTER), report.poster.sha256, "poster SHA mismatch");
|
||||
assert.equal(sha256(VTT), report.vtt.sha256, "VTT SHA mismatch");
|
||||
|
||||
const media = probe(VIDEO);
|
||||
const duration = Number(media.format?.duration ?? 0);
|
||||
assert.ok(duration >= 420 && duration <= 720, `video duration ${duration}s outside 7-12 minutes`);
|
||||
assert.match(String(media.format?.format_name ?? ""), /mp4/);
|
||||
const videoStream = media.streams.find((stream) => stream.codec_type === "video");
|
||||
const audioStream = media.streams.find((stream) => stream.codec_type === "audio");
|
||||
assert.ok(videoStream && audioStream, "video/audio streams missing");
|
||||
assert.equal(videoStream.codec_name, "h264");
|
||||
assert.equal(videoStream.width, 1280);
|
||||
assert.equal(videoStream.height, 720);
|
||||
assert.equal(audioStream.codec_name, "aac");
|
||||
const head = fs.readFileSync(VIDEO).subarray(0, 2 * 1024 * 1024);
|
||||
const moov = head.indexOf(Buffer.from("moov"));
|
||||
const mdat = head.indexOf(Buffer.from("mdat"));
|
||||
assert.ok(moov >= 0 && mdat >= 0 && moov < mdat, "MP4 is not faststart (moov must precede mdat)");
|
||||
|
||||
const vttText = fs.readFileSync(VTT, "utf8");
|
||||
assert.ok(vttText.startsWith("WEBVTT"));
|
||||
const cues = [...vttText.matchAll(/^(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})$/gm)];
|
||||
assert.equal(cues.length, 13, `expected 13 VTT cues, got ${cues.length}`);
|
||||
let previous = -1;
|
||||
for (const cue of cues) {
|
||||
const start = cue[1];
|
||||
const parts = start.split(":").map(Number);
|
||||
const seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
assert.ok(seconds >= previous, "VTT cue times must be monotonic");
|
||||
previous = seconds;
|
||||
}
|
||||
|
||||
const posterMedia = probe(POSTER);
|
||||
const posterStream = posterMedia.streams.find((stream) => stream.codec_type === "video");
|
||||
assert.equal(posterStream.width, 1280);
|
||||
assert.equal(posterStream.height, 720);
|
||||
|
||||
for (const chapter of timeline.chapters) {
|
||||
const file = path.join(REPO, chapter.screenshot);
|
||||
exists(file, `chapter screenshot ${chapter.id}`);
|
||||
const mediaInfo = probe(file);
|
||||
const stream = mediaInfo.streams.find((item) => item.codec_type === "video");
|
||||
assert.equal(stream.width, 1600, `chapter ${chapter.id} width`);
|
||||
assert.equal(stream.height, 900, `chapter ${chapter.id} height`);
|
||||
}
|
||||
assert.equal(new Set(timeline.chapters.map((chapter) => chapter.id)).size, 13);
|
||||
|
||||
const html = fs.readFileSync(HTML, "utf8");
|
||||
assert.ok(html.includes('class="lesson-video"'));
|
||||
assert.ok(html.includes('type="video/mp4"'));
|
||||
assert.ok(html.includes('re-pbr-lesson.zh-Hans.vtt'));
|
||||
assert.ok(html.includes('poster="/tutorial/pbr/lesson/video/poster.jpg"'));
|
||||
assert.ok(!/https?:\/\//i.test(html), "lesson must not reference remote URLs");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
status: "PASS",
|
||||
video: { path: path.relative(REPO, VIDEO), bytes: fs.statSync(VIDEO).size, durationSeconds: Number(duration.toFixed(2)), codecs: { video: videoStream.codec_name, audio: audioStream.codec_name }, resolution: `${videoStream.width}x${videoStream.height}`, faststart: true },
|
||||
narration: { voice: narration.voice, chapters: narration.chapters.length },
|
||||
vttCues: cues.length,
|
||||
chapters: timeline.chapters.length,
|
||||
poster: "1280x720",
|
||||
}, null, 2));
|
||||
@@ -42,9 +42,15 @@ function resolvedFile(root, pathname) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function strongEtag(filePath) {
|
||||
const etagCache = new Map();
|
||||
function strongEtag(filePath, stat) {
|
||||
const key = path.resolve(filePath);
|
||||
const cached = etagCache.get(key);
|
||||
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.etag;
|
||||
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
return `"sha256-${hash}"`;
|
||||
const etag = `"sha256-${hash}"`;
|
||||
etagCache.set(key, { size: stat.size, mtimeMs: stat.mtimeMs, etag });
|
||||
return etag;
|
||||
}
|
||||
|
||||
function matchesIfNoneMatch(value, etag) {
|
||||
@@ -121,7 +127,7 @@ export function createDeploymentHttpServer({ root, contract = loadDeploymentCont
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (expectedAuthorization) { headers["Cache-Control"] = "private, no-store"; headers.Vary = "Authorization"; }
|
||||
const rangeEnabled = contract.rangeRequests.extensions.includes(extension);
|
||||
const etag = strongEtag(filePath);
|
||||
const etag = strongEtag(filePath, stat);
|
||||
headers.ETag = etag;
|
||||
if (rangeEnabled) headers["Accept-Ranges"] = contract.rangeRequests.unit;
|
||||
if (matchesIfNoneMatch(request.headers["if-none-match"], etag)) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* <dir>/manifest.json
|
||||
*
|
||||
* Usage: node tools/web/generate-pbr-library-manifest.mjs [--dir <path>]
|
||||
* [--library-id <id>] [--library-name <name>] [--source <virtual path>] [--blend <file>]
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
@@ -20,14 +21,16 @@ import { pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const REPO = path.resolve(import.meta.dirname, "../..");
|
||||
const dirIndex = process.argv.indexOf("--dir");
|
||||
const DIR = dirIndex > 0 && process.argv[dirIndex + 1]
|
||||
? path.resolve(process.argv[dirIndex + 1])
|
||||
: path.join(REPO, "web/app/public/assets/pbr-library");
|
||||
const flag = (name, fallback) => {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index > 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
||||
};
|
||||
const DIR = path.resolve(flag("dir", path.join(REPO, "web/app/public/assets/pbr-library")));
|
||||
|
||||
const LIBRARY_ID = "library:pbr-essentials-v1";
|
||||
const LIBRARY_NAME = "PBR Essentials";
|
||||
const LIBRARY_SOURCE = "libraries/pbr-library.blend";
|
||||
const LIBRARY_ID = flag("library-id", "library:pbr-essentials-v1");
|
||||
const LIBRARY_NAME = flag("library-name", "PBR Essentials");
|
||||
const LIBRARY_SOURCE = flag("source", "libraries/pbr-library.blend");
|
||||
const BLEND_FILE = flag("blend", "pbr-library.blend");
|
||||
|
||||
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
|
||||
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
||||
@@ -51,7 +54,7 @@ async function loadProtocol() {
|
||||
|
||||
const desktop = readJson("desktop-canonical.json");
|
||||
const previewManifest = readJson("preview-manifest.json");
|
||||
const libraryBytes = fs.readFileSync(path.join(DIR, "pbr-library.blend"));
|
||||
const libraryBytes = fs.readFileSync(path.join(DIR, BLEND_FILE));
|
||||
const sourceSha256 = sha256(libraryBytes);
|
||||
|
||||
const previews = new Map();
|
||||
|
||||
@@ -92,23 +92,19 @@ def add_wood_texture(material, principled):
|
||||
pixels[index + 3] = 1.0
|
||||
image = bpy.data.images.new("PBR Wood Grain", width=size, height=size, alpha=False)
|
||||
image.pixels = pixels
|
||||
image.filepath_raw = str(pathlib.Path(bpy.app.tempdir) / "pbr-wood-grain.png")
|
||||
image.file_format = "PNG"
|
||||
image.save()
|
||||
image.pack()
|
||||
pathlib.Path(image.filepath_raw).unlink(missing_ok=True)
|
||||
image.name = "PBR Wood Grain"
|
||||
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
uv = nodes.new("ShaderNodeUVMap")
|
||||
uv.name = "PBR Wood UV"
|
||||
uv.uv_map = "UVMap"
|
||||
mapping = nodes.new("ShaderNodeMapping")
|
||||
mapping.name = "PBR Wood Mapping"
|
||||
mapping.inputs["Scale"].default_value = (2.0, 4.0, 1.0)
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
texture.name = "PBR Wood Image"
|
||||
texture.image = image
|
||||
texture.interpolation = "Linear"
|
||||
links.new(uv.outputs["UV"], mapping.inputs["Vector"])
|
||||
links.new(mapping.outputs["Vector"], texture.inputs["Vector"])
|
||||
links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
||||
set_input(principled, ["Roughness"], 0.52)
|
||||
set_input(principled, ["Metallic"], 0.0)
|
||||
@@ -145,8 +141,10 @@ def build_materials():
|
||||
materials.append((plastic_white, CATALOG_BY_PATH["Materials/Plastic"], "Matte white plastic", ("plastic", "white")))
|
||||
|
||||
wood, p = new_material("PBR Wood")
|
||||
add_wood_texture(wood, p)
|
||||
materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Varnished wood with packed grain", ("wood", "texture")))
|
||||
set_input(p, ["Base Color"], (0.32, 0.16, 0.06, 1.0))
|
||||
set_input(p, ["Metallic"], 0.0)
|
||||
set_input(p, ["Roughness"], 0.52)
|
||||
materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Warm matte wood colour", ("wood", "warm")))
|
||||
|
||||
glass, p = new_material("PBR Glass")
|
||||
set_input(p, ["Base Color"], (0.92, 0.96, 1.0, 1.0))
|
||||
@@ -230,6 +228,13 @@ def build_model(kind, name, material, catalog_path):
|
||||
obj.data.name = f"{name} Mesh"
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
ensure_uv(obj)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(material)
|
||||
obj.asset_mark()
|
||||
|
||||
130
tools/web/generate-pbr-tutorial-renders.py
Normal file
130
tools/web/generate-pbr-tutorial-renders.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Render the 12 tutorial material/model combinations with desktop Blender.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-pbr-tutorial-renders.py -- \
|
||||
web/app/public/tutorial/pbr/studio.blend \
|
||||
web/app/public/assets/pbr-library/pbr-library.blend \
|
||||
web/app/public/tutorial/pbr/renders
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
MODELS = [
|
||||
("PBR Suzanne", "suzanne"),
|
||||
("PBR Cube", "cube"),
|
||||
("PBR Torus", "torus"),
|
||||
]
|
||||
MATERIALS = [
|
||||
("PBR Metal Brushed", "metal-brushed"),
|
||||
("PBR Glass", "glass"),
|
||||
("PBR Wood", "wood"),
|
||||
("PBR Emission Cyan", "emission-cyan"),
|
||||
]
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def append_library(blend_path):
|
||||
with bpy.data.libraries.load(str(blend_path), link=False) as (data_from, data_to):
|
||||
data_to.objects = list(data_from.objects)
|
||||
data_to.materials = list(data_from.materials)
|
||||
data_to.images = list(data_from.images)
|
||||
return data_to.objects, data_to.materials
|
||||
|
||||
|
||||
def main(studio_path, library_path, renders_dir):
|
||||
studio = pathlib.Path(studio_path).resolve()
|
||||
library = pathlib.Path(library_path).resolve()
|
||||
out = pathlib.Path(renders_dir).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=str(studio))
|
||||
scene = bpy.context.scene
|
||||
objects, materials = append_library(library)
|
||||
material_by_name = {material.name: material for material in materials if material}
|
||||
model_by_name = {}
|
||||
for obj in objects:
|
||||
if obj is None:
|
||||
continue
|
||||
scene.collection.objects.link(obj)
|
||||
model_by_name[obj.name] = obj
|
||||
obj.hide_render = True
|
||||
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 1024
|
||||
scene.render.resolution_y = 1024
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.image_settings.color_depth = "8"
|
||||
scene.render.film_transparent = False
|
||||
scene.render.dither_intensity = 0
|
||||
scene.eevee.taa_render_samples = 64
|
||||
scene.view_settings.look = "None"
|
||||
|
||||
manifest = []
|
||||
for model_name, model_slug in MODELS:
|
||||
model = model_by_name[model_name]
|
||||
for material_name, material_slug in MATERIALS:
|
||||
for other in model_by_name.values():
|
||||
other.hide_render = other is not model
|
||||
material = material_by_name.get(material_name)
|
||||
if material is None:
|
||||
raise RuntimeError(f"missing material {material_name}")
|
||||
model.data.materials.clear()
|
||||
model.data.materials.append(material)
|
||||
png = out / f"{model_slug}-{material_slug}.png"
|
||||
jpg = out / f"{model_slug}-{material_slug}.jpg"
|
||||
scene.render.filepath = str(png)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
scene.render.image_settings.file_format = "JPEG"
|
||||
scene.render.image_settings.color_mode = "RGB"
|
||||
scene.render.image_settings.quality = 90
|
||||
scene.render.filepath = str(jpg)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
manifest.append({
|
||||
"model": model_name,
|
||||
"modelSlug": model_slug,
|
||||
"material": material_name,
|
||||
"materialSlug": material_slug,
|
||||
"png": str(png.relative_to(pathlib.Path(__file__).resolve().parents[2])).replace("\\", "/"),
|
||||
"jpg": str(jpg.relative_to(pathlib.Path(__file__).resolve().parents[2])).replace("\\", "/"),
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"bytes": png.stat().st_size,
|
||||
"sha256": sha256_file(png),
|
||||
})
|
||||
|
||||
(out / "manifest.json").write_text(
|
||||
json.dumps({
|
||||
"schemaVersion": 1,
|
||||
"renderer": "DESKTOP_BLENDER_EEVEE",
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
"renders": manifest,
|
||||
}, indent=2, ensure_ascii=True) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
print(json.dumps({"renders": len(manifest), "output": str(out)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 3:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-tutorial-renders.py -- STUDIO LIBRARY RENDERS")
|
||||
main(*arguments)
|
||||
161
tools/web/generate-pbr-tutorial-studio.py
Normal file
161
tools/web/generate-pbr-tutorial-studio.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Generate the PBR tutorial studio scene with desktop Blender.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-pbr-tutorial-studio.py -- \
|
||||
web/app/public/tutorial/pbr/studio.blend \
|
||||
tests/golden/PBR_TUTORIAL-001/studio-build.json
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def add_area_light(name, location, energy, size):
|
||||
# Point lights match the bounded three.js PBR renderer without needing
|
||||
# RectAreaLight uniform support; the original size becomes a soft falloff.
|
||||
bpy.ops.object.light_add(type="POINT", location=location)
|
||||
light = bpy.context.object
|
||||
light.name = name
|
||||
light.data.energy = energy
|
||||
light.data.shadow_soft_size = size * 0.5
|
||||
light.rotation_euler = (-Vector(location)).to_track_quat("-Z", "Y").to_euler()
|
||||
return light
|
||||
|
||||
|
||||
def main(output_blend, report_path):
|
||||
output = pathlib.Path(output_blend).resolve()
|
||||
report = pathlib.Path(report_path).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.name = "PBR Studio"
|
||||
|
||||
bpy.ops.object.camera_add(location=(7.2, -7.2, 5.4))
|
||||
camera = bpy.context.object
|
||||
camera.name = "PBR Studio Camera"
|
||||
camera.data.name = "PBR Studio Camera"
|
||||
camera.data.type = "PERSP"
|
||||
camera.data.lens = 55
|
||||
camera.data.sensor_width = 36
|
||||
camera.data.sensor_fit = "HORIZONTAL"
|
||||
camera.data.clip_start = 0.01
|
||||
camera.data.clip_end = 1000
|
||||
camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
scene.camera = camera
|
||||
|
||||
key = add_area_light("PBR Studio Key", (3.2, -3.2, 4.0), 900, 4.0)
|
||||
fill = add_area_light("PBR Studio Fill", (-4.0, -2.0, 2.0), 500, 5.0)
|
||||
rim = add_area_light("PBR Studio Rim", (0.0, 4.0, 3.0), 700, 3.0)
|
||||
bpy.ops.object.light_add(type="SUN", location=(4.0, -4.0, 6.0))
|
||||
sun = bpy.context.object
|
||||
sun.name = "PBR Studio Sun"
|
||||
sun.data.energy = 30.0
|
||||
sun.data.angle = 0.15
|
||||
sun.rotation_euler = (-sun.location).to_track_quat("-Z", "Y").to_euler()
|
||||
|
||||
world = bpy.data.worlds.new("PBR Studio World")
|
||||
world.use_nodes = True
|
||||
background = world.node_tree.nodes.get("Background")
|
||||
background.inputs["Color"].default_value = (0.045, 0.05, 0.06, 1.0)
|
||||
background.inputs["Strength"].default_value = 1.4
|
||||
|
||||
# Packed equirectangular studio environment: point lights alone leave
|
||||
# metallic materials almost black in the bounded MeshPhysicalMaterial path.
|
||||
width, height = 512, 256
|
||||
pixels = [0.0] * (width * height * 4)
|
||||
for y in range(height):
|
||||
v = y / (height - 1)
|
||||
base = 0.35 + 0.65 * (v ** 1.2)
|
||||
for x in range(width):
|
||||
u = x / (width - 1)
|
||||
glow = 0.0
|
||||
for cx, cy, radius, strength in (
|
||||
(0.20, 0.78, 0.12, 5.0),
|
||||
(0.52, 0.85, 0.10, 4.0),
|
||||
(0.82, 0.70, 0.13, 3.0),
|
||||
):
|
||||
dx = (u - cx) * 2.0
|
||||
dy = (v - cy) * 1.4
|
||||
glow += strength * max(0.0, 1.0 - (dx * dx + dy * dy) / (radius * radius))
|
||||
value = min(8.0, base + glow)
|
||||
index = (y * width + x) * 4
|
||||
pixels[index] = value
|
||||
pixels[index + 1] = value * 1.02
|
||||
pixels[index + 2] = value * 1.06
|
||||
pixels[index + 3] = 1.0
|
||||
environment = bpy.data.images.new("PBR Studio Environment", width=width, height=height, alpha=False)
|
||||
environment.pixels = pixels
|
||||
environment.filepath_raw = str(output.parent / "_studio-environment.png")
|
||||
environment.file_format = "PNG"
|
||||
environment.save()
|
||||
environment.pack()
|
||||
pathlib.Path(environment.filepath_raw).unlink(missing_ok=True)
|
||||
environment_node = world.node_tree.nodes.new("ShaderNodeTexEnvironment")
|
||||
environment_node.name = "PBR Studio Environment"
|
||||
environment_node.image = environment
|
||||
world.node_tree.links.new(environment_node.outputs["Color"], background.inputs["Color"])
|
||||
scene.world = world
|
||||
|
||||
bpy.ops.mesh.primitive_plane_add(size=20.0, location=(0.0, 0.0, -1.0))
|
||||
ground = bpy.context.object
|
||||
ground.name = "PBR Studio Ground"
|
||||
ground.data.name = "PBR Studio Ground Mesh"
|
||||
material = bpy.data.materials.new("PBR Studio Ground Material")
|
||||
material.use_nodes = True
|
||||
principled = material.node_tree.nodes.get("Principled BSDF")
|
||||
principled.inputs["Base Color"].default_value = (0.32, 0.32, 0.33, 1.0)
|
||||
principled.inputs["Roughness"].default_value = 0.65
|
||||
principled.inputs["Metallic"].default_value = 0.0
|
||||
ground.data.materials.append(material)
|
||||
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x = 1024
|
||||
scene.render.resolution_y = 1024
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.view_settings.look = "None"
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True)
|
||||
|
||||
lights = [light.name for light in (key, fill, rim, sun)]
|
||||
payload = {
|
||||
"schemaVersion": 1,
|
||||
"generator": "tools/web/generate-pbr-tutorial-studio.py",
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
"blenderExecutable": bpy.app.binary_path,
|
||||
"blend": {
|
||||
"path": str(output),
|
||||
"sha256": sha256_file(output),
|
||||
"byteLength": output.stat().st_size,
|
||||
},
|
||||
"objects": sorted(object.name for object in scene.objects),
|
||||
"camera": camera.name,
|
||||
"lights": lights,
|
||||
"world": world.name,
|
||||
"ground": ground.name,
|
||||
}
|
||||
report.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
|
||||
print(json.dumps({"studio": str(output), "sha256": payload["blend"]["sha256"], "objects": payload["objects"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-tutorial-studio.py -- BLEND REPORT")
|
||||
main(arguments[0], arguments[1])
|
||||
329
tools/web/generate-re-pbr-library.py
Normal file
329
tools/web/generate-re-pbr-library.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Derive the Web Blender RE 5000PBR asset library from the desktop catalog.
|
||||
|
||||
Usage:
|
||||
build_blender_native/bin/blender --background --factory-startup \
|
||||
--python tools/web/generate-re-pbr-library.py -- \
|
||||
"/path/to/5000+ PBR catalog.blend" OUTPUT_DIRECTORY \
|
||||
[--base-color-size 1024] [--normal-size 1024] [--scalar-size 512] [--preview-size 256]
|
||||
|
||||
The source .blend is never modified. The derived library keeps the original
|
||||
shader graphs (FRAME/REROUTE/DISPLACEMENT/MAPPING/VALUE included) so the Web
|
||||
compiler/reader must support them; this script only marks assets, downsamples
|
||||
packed textures, repacks them and renders material previews.
|
||||
|
||||
Outputs (inside OUTPUT_DIRECTORY):
|
||||
re-pbr-library.blend six marked MATERIAL assets, no scene objects
|
||||
blender_assets.cats.txt Materials/RE5000PBR catalog
|
||||
previews/<sha256>.png 256x256 EEVEE material previews
|
||||
preview-manifest.json
|
||||
build-report.json
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
CATALOG_ID = "a8000000-0000-4000-8000-000000000001"
|
||||
CATALOG_PATH = "Materials/RE5000PBR"
|
||||
CATALOG_NAME = "RE5000PBR"
|
||||
LIBRARY_NAME = "re-pbr-library.blend"
|
||||
EXPECTED_MATERIALS = 6
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def load_common():
|
||||
path = pathlib.Path(__file__).with_name("generate-pbr-library.py")
|
||||
spec = importlib.util.spec_from_file_location("pbr_library_common", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) < 2:
|
||||
raise SystemExit(
|
||||
"usage: blender -b --python generate-re-pbr-library.py -- "
|
||||
"SOURCE_BLEND OUTPUT_DIR [--base-color-size N] [--normal-size N] "
|
||||
"[--scalar-size N] [--preview-size N]"
|
||||
)
|
||||
source = pathlib.Path(arguments[0]).resolve()
|
||||
output = pathlib.Path(arguments[1]).resolve()
|
||||
options = {
|
||||
"base_color_size": 1024,
|
||||
"normal_size": 1024,
|
||||
"scalar_size": 512,
|
||||
"preview_size": 256,
|
||||
"preserve_original": False,
|
||||
"material_suffix": "",
|
||||
"library_id": "library:re-pbr-v1",
|
||||
}
|
||||
index = 2
|
||||
while index < len(arguments):
|
||||
flag = arguments[index]
|
||||
if flag == "--preserve-original":
|
||||
options["preserve_original"] = True
|
||||
index += 1
|
||||
continue
|
||||
if flag not in {"--base-color-size", "--normal-size", "--scalar-size", "--preview-size", "--texture-size", "--material-suffix", "--library-id"}:
|
||||
raise SystemExit(f"unknown option {flag}")
|
||||
if index + 1 >= len(arguments):
|
||||
raise SystemExit(f"missing value for {flag}")
|
||||
key = flag.removeprefix("--").replace("-", "_")
|
||||
value = arguments[index + 1]
|
||||
if key == "texture_size":
|
||||
size = int(value)
|
||||
options["base_color_size"] = size
|
||||
options["normal_size"] = size
|
||||
options["scalar_size"] = size
|
||||
elif key in {"base_color_size", "normal_size", "scalar_size", "preview_size"}:
|
||||
options[key] = int(value)
|
||||
else:
|
||||
options[key] = value
|
||||
index += 2
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"source blend not found: {source}")
|
||||
return source, output, options
|
||||
|
||||
|
||||
def target_size(image_name: str, options) -> int:
|
||||
lowered = image_name.lower()
|
||||
if "normal" in lowered:
|
||||
return options["normal_size"]
|
||||
if "base_color" in lowered or "basecolor" in lowered or "albedo" in lowered:
|
||||
return options["base_color_size"]
|
||||
return options["scalar_size"]
|
||||
|
||||
|
||||
def resample_image(image, size: int, temporary: pathlib.Path, preserve_original: bool = False):
|
||||
if image.size[0] == 0 or image.size[1] == 0:
|
||||
return
|
||||
if preserve_original and image.size[0] == size and image.size[1] == size:
|
||||
return
|
||||
if image.size[0] != size or image.size[1] != size:
|
||||
image.scale(size, size)
|
||||
image.file_format = "JPEG"
|
||||
temporary.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
image.save(filepath=str(temporary), quality=84)
|
||||
except TypeError:
|
||||
image.filepath_raw = str(temporary)
|
||||
image.save()
|
||||
if not temporary.is_file() or temporary.stat().st_size == 0:
|
||||
raise RuntimeError(f"failed to write resampled image {temporary}")
|
||||
data = temporary.read_bytes()
|
||||
# Image.pack() with no data re-packs the original packed file. Pack the
|
||||
# resampled JPEG bytes explicitly so the library actually shrinks.
|
||||
image.pack(data=data, data_len=len(data))
|
||||
|
||||
|
||||
def material_uses_principled(material) -> bool:
|
||||
if not material.use_nodes or material.node_tree is None:
|
||||
return False
|
||||
return any(node.type == "BSDF_PRINCIPLED" for node in material.node_tree.nodes)
|
||||
|
||||
|
||||
def collect_materials():
|
||||
materials = [
|
||||
material for material in bpy.data.materials
|
||||
if material.name != "Dots Stroke" and material_uses_principled(material)
|
||||
]
|
||||
materials.sort(key=lambda material: material.name)
|
||||
if len(materials) != EXPECTED_MATERIALS:
|
||||
raise RuntimeError(
|
||||
f"expected {EXPECTED_MATERIALS} Principled materials, found {len(materials)}: "
|
||||
+ ", ".join(material.name for material in materials)
|
||||
)
|
||||
return materials
|
||||
|
||||
|
||||
def write_catalog_definition(path: pathlib.Path):
|
||||
lines = [
|
||||
"# This is an Asset Catalog Definition file for Blender.",
|
||||
"# Derived from the desktop RE 5000PBR catalog by generate-re-pbr-library.py",
|
||||
"",
|
||||
"VERSION 1",
|
||||
"",
|
||||
f"{CATALOG_ID}:{CATALOG_PATH}:{CATALOG_NAME}",
|
||||
]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def mark_materials(materials):
|
||||
for material in materials:
|
||||
if material.asset_data is None:
|
||||
material.asset_mark()
|
||||
material.asset_data.catalog_id = CATALOG_ID
|
||||
material.asset_data.description = "RE 5000PBR material for the Web Blender render lesson"
|
||||
material.asset_data.tags.new("PBR")
|
||||
material.asset_data.tags.new("RE5000PBR")
|
||||
|
||||
|
||||
def resample_material_images(materials, options, temporary_dir: pathlib.Path):
|
||||
processed = set()
|
||||
resampled = []
|
||||
for material in materials:
|
||||
if material.node_tree is None:
|
||||
continue
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type != "TEX_IMAGE" or node.image is None:
|
||||
continue
|
||||
image = node.image
|
||||
if image.name in processed or not image.packed_file:
|
||||
continue
|
||||
processed.add(image.name)
|
||||
size = target_size(image.name, options)
|
||||
temporary = temporary_dir / f"{sha256_bytes(image.name.encode('utf-8'))[:16]}.jpg"
|
||||
before = image.size[:]
|
||||
colorspace = image.colorspace_settings.name
|
||||
resample_image(image, size, temporary, options.get("preserve_original", False))
|
||||
image.colorspace_settings.name = colorspace
|
||||
resampled.append({
|
||||
"name": image.name,
|
||||
"from": list(before),
|
||||
"to": list(image.size[:]),
|
||||
"colorspace": colorspace,
|
||||
"packedBytes": image.packed_file.size if image.packed_file else 0,
|
||||
})
|
||||
return resampled
|
||||
|
||||
|
||||
def remove_scene_objects():
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
|
||||
def remove_unused_data(materials):
|
||||
keep_material = {material.name for material in materials}
|
||||
for material in list(bpy.data.materials):
|
||||
if material.name not in keep_material:
|
||||
bpy.data.materials.remove(material)
|
||||
used_images = set()
|
||||
for material in materials:
|
||||
if material.node_tree is None:
|
||||
continue
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||
used_images.add(node.image.name)
|
||||
for image in list(bpy.data.images):
|
||||
if image.name not in used_images and not image.name.startswith("Render Result"):
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
def render_previews(common, materials, preview_dir: pathlib.Path, preview_size: int):
|
||||
common.PREVIEW_SIZE = preview_size
|
||||
scene = bpy.context.scene
|
||||
camera = common.configure_preview_scene(scene)
|
||||
scene.render.resolution_x = preview_size
|
||||
scene.render.resolution_y = preview_size
|
||||
entries = []
|
||||
for material in materials:
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=96, ring_count=64, radius=1.0, location=(0.0, 0.0, 0.0))
|
||||
sphere = bpy.context.object
|
||||
sphere.name = f"Preview {material.name}"
|
||||
bpy.ops.object.shade_smooth()
|
||||
sphere.data.materials.append(material)
|
||||
common.frame_object(scene, camera, sphere)
|
||||
temporary = preview_dir / "tmp.png"
|
||||
common.render_preview(scene, temporary)
|
||||
entry = common.store_preview(temporary, preview_dir)
|
||||
bpy.data.objects.remove(sphere, do_unlink=True)
|
||||
entry.update({"idType": "MATERIAL", "name": material.name})
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def main():
|
||||
source, output, options = parse_arguments()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir = output / "previews"
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
for stale in preview_dir.glob("*.png"):
|
||||
stale.unlink()
|
||||
|
||||
common = load_common()
|
||||
bpy.ops.wm.open_mainfile(filepath=str(source))
|
||||
materials = collect_materials()
|
||||
suffix = str(options.get("material_suffix", ""))
|
||||
if suffix:
|
||||
for material in materials:
|
||||
material.name = f"{material.name}{suffix}"
|
||||
mark_materials(materials)
|
||||
temporary_dir = output / ".resample"
|
||||
resampled = resample_material_images(materials, options, temporary_dir)
|
||||
remove_scene_objects()
|
||||
remove_unused_data(materials)
|
||||
|
||||
catalog_path = output / "blender_assets.cats.txt"
|
||||
write_catalog_definition(catalog_path)
|
||||
library_path = output / LIBRARY_NAME
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True)
|
||||
|
||||
preview_entries = render_previews(common, materials, preview_dir, options["preview_size"])
|
||||
preview_manifest = output / "preview-manifest.json"
|
||||
preview_manifest.write_text(
|
||||
json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
for item in temporary_dir.glob("*"):
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
if temporary_dir.is_dir():
|
||||
temporary_dir.rmdir()
|
||||
|
||||
source_sha = sha256_file(source)
|
||||
library_sha = sha256_file(library_path)
|
||||
preview_bytes = sum(entry["byteLength"] for entry in preview_entries)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"generator": "tools/web/generate-re-pbr-library.py",
|
||||
"scriptSha256": sha256_file(pathlib.Path(__file__).resolve()),
|
||||
"source": {"path": str(source), "sha256": source_sha, "byteLength": source.stat().st_size},
|
||||
"library": {
|
||||
"path": str(library_path),
|
||||
"sha256": library_sha,
|
||||
"byteLength": library_path.stat().st_size,
|
||||
"libraryId": "library:re-pbr-v1",
|
||||
"name": LIBRARY_NAME,
|
||||
},
|
||||
"catalog": {"id": CATALOG_ID, "path": CATALOG_PATH, "name": CATALOG_NAME},
|
||||
"materials": [material.name for material in materials],
|
||||
"textures": resampled,
|
||||
"textureOptions": options,
|
||||
"previewCount": len(preview_entries),
|
||||
"previewBytes": preview_bytes,
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
}
|
||||
(output / "build-report.json").write_text(
|
||||
json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
|
||||
)
|
||||
print(json.dumps({
|
||||
"materials": len(materials),
|
||||
"previews": len(preview_entries),
|
||||
"librarySha256": library_sha,
|
||||
"libraryBytes": library_path.stat().st_size,
|
||||
"textureBytes": sum(item["packedBytes"] for item in resampled),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
184
tools/web/generate-re-pbr-narration.py
Normal file
184
tools/web/generate-re-pbr-narration.py
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare Chinese narration and mix it into the RE 5000PBR lesson video.
|
||||
|
||||
Usage:
|
||||
python3 generator.py --mode prepare \
|
||||
--chapters /home/.../tools/web/re-pbr-video-chapters.json \
|
||||
--out /home/.../tests/golden/RE-PBR-VIDEO-001
|
||||
python3 generator.py --mode mix \
|
||||
--out /home/.../tests/golden/RE-PBR-VIDEO-001 \
|
||||
--video /home/.../web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import wave
|
||||
|
||||
EDGE_TTS = pathlib.Path("/home/mes123456/workinf_Blender_Wasm/tools/web/.tts-venv/bin/edge-tts")
|
||||
FFMPEG = pathlib.Path("/usr/bin/ffmpeg")
|
||||
FFPROBE = pathlib.Path("/usr/bin/ffprobe")
|
||||
SAMPLE_RATE = 48000
|
||||
SAMPLE_WIDTH = 2
|
||||
CHANNELS = 1
|
||||
|
||||
|
||||
def run(command, **kwargs):
|
||||
return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs)
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def prepare(chapters_path, output):
|
||||
chapters = json.loads(chapters_path.read_text(encoding="utf-8"))
|
||||
voice = chapters.get("voice", "zh-CN-XiaoxiaoNeural")
|
||||
audio_dir = output / "narration"
|
||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
prepared = []
|
||||
for chapter in chapters["chapters"]:
|
||||
chapter_id = chapter["id"]
|
||||
mp3 = audio_dir / f"{chapter_id}.mp3"
|
||||
wav = audio_dir / f"{chapter_id}.wav"
|
||||
if not mp3.exists():
|
||||
run([str(EDGE_TTS), "--voice", voice, "--text", chapter["narration"], "--write-media", str(mp3)])
|
||||
if not wav.exists() or wav.stat().st_mtime < mp3.stat().st_mtime:
|
||||
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(mp3),
|
||||
"-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "-c:a", "pcm_s16le", str(wav)])
|
||||
duration = float(run([str(FFPROBE), "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", str(wav)]).stdout.strip())
|
||||
prepared.append({
|
||||
"id": chapter_id,
|
||||
"title": chapter["title"],
|
||||
"narration": chapter["narration"],
|
||||
"mp3": str(mp3),
|
||||
"wav": str(wav),
|
||||
"durationMs": int(round(duration * 1000)),
|
||||
})
|
||||
result = {"voice": voice, "chapters": prepared}
|
||||
(output / "narration.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
total = sum(item["durationMs"] for item in prepared)
|
||||
print(json.dumps({"status": "PREPARED", "voice": voice, "chapters": len(prepared), "narrationMs": total}, ensure_ascii=False))
|
||||
|
||||
|
||||
def read_pcm(path):
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
if handle.getnchannels() != CHANNELS or handle.getsampwidth() != SAMPLE_WIDTH or handle.getframerate() != SAMPLE_RATE:
|
||||
raise SystemExit(f"unexpected WAV format: {path}")
|
||||
return handle.readframes(handle.getnframes())
|
||||
|
||||
|
||||
def overlay(buffer, samples, offset_samples):
|
||||
for index in range(0, len(samples), 2):
|
||||
target = (offset_samples * 2) + index
|
||||
if target + 1 >= len(buffer):
|
||||
break
|
||||
left = int.from_bytes(buffer[target:target + 2], "little", signed=True)
|
||||
right = int.from_bytes(samples[index:index + 2], "little", signed=True)
|
||||
mixed = max(-32768, min(32767, left + right))
|
||||
buffer[target:target + 2] = mixed.to_bytes(2, "little", signed=True)
|
||||
|
||||
|
||||
def timestamp(milliseconds):
|
||||
seconds, ms = divmod(max(0, int(milliseconds)), 1000)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"
|
||||
|
||||
|
||||
def mix(output, video):
|
||||
timeline_path = output / "timeline.json"
|
||||
narration_path = output / "narration.json"
|
||||
if not timeline_path.exists():
|
||||
raise SystemExit(f"missing recording timeline: {timeline_path}")
|
||||
if not narration_path.exists():
|
||||
raise SystemExit(f"missing narration: {narration_path}")
|
||||
timeline = json.loads(timeline_path.read_text(encoding="utf-8"))
|
||||
narration = json.loads(narration_path.read_text(encoding="utf-8"))
|
||||
by_id = {chapter["id"]: chapter for chapter in narration["chapters"]}
|
||||
total_ms = int(timeline.get("durationMs", 0))
|
||||
for chapter in timeline.get("chapters", []):
|
||||
total_ms = max(total_ms, int(chapter.get("endMs", 0)) + 800)
|
||||
total_samples = int(total_ms / 1000 * SAMPLE_RATE)
|
||||
buffer = bytearray(total_samples * 2)
|
||||
cues = []
|
||||
for chapter in timeline.get("chapters", []):
|
||||
prepared = by_id.get(chapter["id"])
|
||||
if not prepared:
|
||||
continue
|
||||
start_ms = int(chapter.get("startMs", 0))
|
||||
samples = read_pcm(pathlib.Path(prepared["wav"]))
|
||||
overlay(buffer, samples, int(start_ms / 1000 * SAMPLE_RATE))
|
||||
cues.append({
|
||||
"id": chapter["id"],
|
||||
"title": prepared["title"],
|
||||
"startMs": start_ms,
|
||||
"endMs": start_ms + prepared["durationMs"],
|
||||
"narration": prepared["narration"],
|
||||
})
|
||||
narration_wav = output / "narration.wav"
|
||||
with wave.open(str(narration_wav), "wb") as handle:
|
||||
handle.setnchannels(CHANNELS)
|
||||
handle.setsampwidth(SAMPLE_WIDTH)
|
||||
handle.setframerate(SAMPLE_RATE)
|
||||
handle.writeframes(bytes(buffer))
|
||||
vtt = ["WEBVTT", ""]
|
||||
for cue in cues:
|
||||
vtt.extend([f"{timestamp(cue['startMs'])} --> {timestamp(cue['endMs'])}", f"{cue['title']}:{cue['narration']}", ""])
|
||||
vtt_path = video.parent / "re-pbr-lesson.zh-Hans.vtt"
|
||||
vtt_path.write_text("\n".join(vtt), encoding="utf-8")
|
||||
video.parent.mkdir(parents=True, exist_ok=True)
|
||||
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(output / "screencast.webm"),
|
||||
"-i", str(narration_wav), "-vf", "scale=1280:720", "-c:v", "libx264", "-crf", "23", "-preset", "medium",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-shortest", str(video)])
|
||||
poster = video.parent / "poster.jpg"
|
||||
poster_ms = 1000
|
||||
for chapter in timeline.get("chapters", []):
|
||||
if chapter.get("id") == "hq-2048":
|
||||
poster_ms = max(1000, min(int(chapter.get("endMs", 0)) - 8000,
|
||||
int(timeline.get("durationMs", 0)) - 2000))
|
||||
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-ss", f"{poster_ms / 1000:.3f}", "-i", str(video),
|
||||
"-frames:v", "1", "-vf", "scale=1280:720", str(poster)])
|
||||
probe = run([str(FFPROBE), "-v", "error", "-show_entries",
|
||||
"format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", str(video)])
|
||||
(output / "video-probe.json").write_text(probe.stdout + "\n", encoding="utf-8")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"status": "PASS",
|
||||
"voice": narration.get("voice"),
|
||||
"video": {"path": str(video), "sha256": sha256(video), "bytes": video.stat().st_size},
|
||||
"narration": {"path": str(narration_wav), "sha256": sha256(narration_wav), "bytes": narration_wav.stat().st_size},
|
||||
"vtt": {"path": str(vtt_path), "sha256": sha256(vtt_path), "cues": len(cues)},
|
||||
"poster": {"path": str(poster), "sha256": sha256(poster)},
|
||||
"chapters": cues,
|
||||
}
|
||||
(output / "video-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"status": "MIXED", "video": str(video), "bytes": video.stat().st_size,
|
||||
"duration": json.loads(probe.stdout).get("format", {}).get("duration"), "cues": len(cues)}, ensure_ascii=False))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mode", choices=["prepare", "mix"], required=True)
|
||||
parser.add_argument("--chapters", default="/home/mes123456/workinf_Blender_Wasm/tools/web/re-pbr-video-chapters.json")
|
||||
parser.add_argument("--out", default="/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001")
|
||||
parser.add_argument("--video", default="/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4")
|
||||
args = parser.parse_args()
|
||||
output = pathlib.Path(args.out)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if args.mode == "prepare":
|
||||
prepare(pathlib.Path(args.chapters), output)
|
||||
else:
|
||||
mix(output, pathlib.Path(args.video))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
tools/web/re-pbr-video-chapters.json
Normal file
71
tools/web/re-pbr-video-chapters.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"voice": "zh-CN-XiaoxiaoNeural",
|
||||
"chapters": [
|
||||
{
|
||||
"id": "intro",
|
||||
"title": "开场:会得到什么",
|
||||
"narration": "这一课我们从灰色猴头开始。你会打开摄影棚,导入只有一个猴头网格的 GLB 文件,从 RE 五零零零 PBR 资产库分配六种材质,最后在浏览器里用显卡渲染、下载 PNG、保存工程并重新打开。"
|
||||
},
|
||||
{
|
||||
"id": "open-studio",
|
||||
"title": "载入摄影棚",
|
||||
"narration": "先打开 Web Blender 的 Rendering 工作区。在 PBR 渲染练习面板里点击载入练习摄影棚。这个场景已经准备好相机、三点面光、太阳光、世界环境和地面。等待状态栏显示 Engine SceneIR,并且对象数是六。"
|
||||
},
|
||||
{
|
||||
"id": "import-glb",
|
||||
"title": "导入猴头 GLB",
|
||||
"narration": "接下来点击导入猴头 GLB,选择课程目录里的 monkey head 点 GLB。导入会追加到当前场景,不会替换摄影棚。看到对象数从六变成七,活动对象是 Suzanne 就对了。此时猴头没有材质,所以是灰色的,这是正常的基线。"
|
||||
},
|
||||
{
|
||||
"id": "asset-library",
|
||||
"title": "认识 Shading 与 RE 资产库",
|
||||
"narration": "切换到 Shading 工作区。左下角是 PBR 资产库,现在有二十个资产。用资产来源下拉框选择 RE 五零零零 PBR,只留下六张材质卡片。单击卡片只会选中,双击才会把材质分配出去。缩略图加载完成后就可以继续。"
|
||||
},
|
||||
{
|
||||
"id": "assign-materials",
|
||||
"title": "分配六种材质",
|
||||
"narration": "回到 Rendering 工作区。在 PBR 渲染练习面板下方,新加了 RE 五零零零 PBR 材质选择器,它显示完整材质名和缩略图,不受左侧窄栏影响。依次双击六种材质:防滑颗粒、砖墙、随机瓷砖、光亮瓷砖、规则砖墙和做旧砖墙。每次等待材质槽更新,并确认材质球显示 COMPILED。"
|
||||
},
|
||||
{
|
||||
"id": "render-settings",
|
||||
"title": "设置渲染",
|
||||
"narration": "现在设置渲染。分辨率选一零二四,相机选场景相机,视图保持最终材质,教学曝光保持零,位移强度保持一倍,视角预设选场景相机对齐。这样得到的就是固定摄影棚下的标准成片。"
|
||||
},
|
||||
{
|
||||
"id": "render-download",
|
||||
"title": "渲染与下载 PNG",
|
||||
"narration": "点击渲染 PBR。面板会先等待纹理和环境贴图就绪,然后输出一张一零二四乘一零二四的 WebGL2 成片。结果出现后,点击下载 PNG,浏览器会保存 pbr 一零二四点 PNG。"
|
||||
},
|
||||
{
|
||||
"id": "save-reopen",
|
||||
"title": "保存与重开",
|
||||
"narration": "点击顶部的保存项目,下载点 blend 工程。然后再次导入刚才保存的工程;如果询问是否保存当前修改,选择不保存并继续。重新打开后对象数仍是七,材质槽仍然保留做旧砖墙,再渲染一次确认贴图没有丢失。"
|
||||
},
|
||||
{
|
||||
"id": "diagnostics",
|
||||
"title": "诊断视图与教学曝光",
|
||||
"narration": "深色材质只看整体亮度容易误判。把视图切到 Base Color,可以看到基础色贴图;切到 Normal,可以看到法线和砖缝;切到 Roughness,可以看到粗糙度。教学曝光可以加一档或两档 EV,只改变渲染曝光,不改变材质本身。"
|
||||
},
|
||||
{
|
||||
"id": "displacement",
|
||||
"title": "位移与细分",
|
||||
"narration": "Height 贴图现在会真正参与几何位移。位移强度一倍保留源材质零点零零一的 Scale,画面变化很细;切到一百倍只是渲染时的教学放大,不写回材质。静帧会把九百六十八个三角形细分到六万一千九百五十二个,再执行位移。"
|
||||
},
|
||||
{
|
||||
"id": "camera",
|
||||
"title": "对比视角",
|
||||
"narration": "视角预设默认与场景相机精确对齐,所以场景和视口可能看起来一样。选择对比四十五度后,当前视口会以主体为中心偏转三十度,得到明显不同的构图;主视口导航不受影响。"
|
||||
},
|
||||
{
|
||||
"id": "hq-2048",
|
||||
"title": "高清 2048 贴图",
|
||||
"narration": "如果需要原始分辨率,点击加载高清贴图二零四八。素材会按需合并,材质名带 HQ 后缀。切换到高清来源,双击分配做旧砖墙 HQ,再渲染一次。五个纹理全部加载,显存预算约六十四点五兆,低于五百一十二兆上限。"
|
||||
},
|
||||
{
|
||||
"id": "summary",
|
||||
"title": "总结与教案",
|
||||
"narration": "到这里,你已经完成从灰色猴头到六种 PBR 材质的完整流程:打开场景、导入 GLB、分配材质、渲染下载、保存重开,以及诊断、位移、对比相机和高清贴图。更多截图和文字步骤在渲染图文教案里,地址是 tutorial pbr render lesson 点 HTML。"
|
||||
}
|
||||
]
|
||||
}
|
||||
470
tools/web/record-re-pbr-lesson-video.mjs
Normal file
470
tools/web/record-re-pbr-lesson-video.mjs
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/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; });
|
||||
Reference in New Issue
Block a user