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:
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));
|
||||
Reference in New Issue
Block a user